feat(markdown): mark unused attachments to be deleted

This commit is contained in:
Elian Doran
2026-04-28 19:58:58 +03:00
parent 4cdfd80ad1
commit 8a2aff5a68
4 changed files with 234 additions and 11 deletions
+5
View File
@@ -284,6 +284,11 @@ class BNote extends AbstractBeccaEntity<BNote> {
return ["code", "file", "render"].includes(this.type) && this.mime === "text/html";
}
/** @returns true if this note is a Markdown code note */
isMarkdown() {
return this.type === "code" && (this.mime === "text/markdown" || this.mime === "text/x-markdown" || this.mime === "text/x-gfm");
}
/** @returns true if this note is an image */
isImage() {
return this.type === "image" || (this.type === "file" && this.mime?.startsWith("image/"));
+206 -2
View File
@@ -1,5 +1,29 @@
import { describe, expect, it } from "vitest";
import { findBookmarks } from "./notes.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import becca from "../becca/becca.js";
import BAttachment from "../becca/entities/battachment.js";
import { buildNote } from "../test/becca_easy_mocking.js";
import { randomString } from "./utils.js";
import { checkImageAttachments, findBookmarks } from "./notes.js";
vi.mock("./sql.js", () => ({
default: {
transactional: (cb: Function) => cb(),
execute: () => {},
replace: () => {},
upsert: () => {},
getMap: () => ({}),
getManyRows: () => [],
getValue: () => null
}
}));
vi.mock("./ws.js", () => ({
default: { sendMessageToAllClients: () => {} }
}));
vi.mock("./entity_changes.js", () => ({
default: { putEntityChange: () => {} }
}));
describe("findBookmarks", () => {
it("extracts bookmark IDs from empty anchor tags", () => {
@@ -40,3 +64,183 @@ describe("findBookmarks", () => {
expect(findBookmarks(contentNoClose)).toEqual(["my-bookmark"]);
});
});
function makeAttachment(noteId: string, opts: { id?: string; role?: string; scheduledForErasure?: boolean } = {}) {
const attachment = new BAttachment({
attachmentId: opts.id ?? randomString(10),
ownerId: noteId,
title: "test-image.png",
role: opts.role ?? "image",
mime: "image/png"
});
attachment.save = vi.fn();
if (opts.scheduledForErasure) {
attachment.utcDateScheduledForErasureSince = "2025-01-01 00:00:00.000Z";
}
return attachment;
}
describe("checkImageAttachments", () => {
beforeEach(() => {
becca.reset();
});
describe("HTML content", () => {
it("keeps referenced attachments alive", () => {
const note = buildNote({ title: "Test" });
const att = makeAttachment(note.noteId);
note.getAttachments = () => [att];
const content = `<p>Hello</p><img src="api/attachments/${att.attachmentId}/image/test.png">`;
checkImageAttachments(note, content);
expect(att.save).not.toHaveBeenCalled();
});
it("schedules unreferenced attachments for erasure", () => {
const note = buildNote({ title: "Test" });
const att = makeAttachment(note.noteId);
note.getAttachments = () => [att];
checkImageAttachments(note, "<p>No images here</p>");
expect(att.save).toHaveBeenCalled();
expect(att.utcDateScheduledForErasureSince).toBeTruthy();
});
it("cancels erasure when attachment is re-referenced", () => {
const note = buildNote({ title: "Test" });
const att = makeAttachment(note.noteId, { scheduledForErasure: true });
note.getAttachments = () => [att];
const content = `<img src="api/attachments/${att.attachmentId}/image/test.png">`;
checkImageAttachments(note, content);
expect(att.save).toHaveBeenCalled();
expect(att.utcDateScheduledForErasureSince).toBeNull();
});
it("detects attachment IDs in href reference links", () => {
const note = buildNote({ title: "Test" });
const att = makeAttachment(note.noteId, { role: "file" });
note.getAttachments = () => [att];
const content = `<a href="#root/${note.noteId}?viewMode=attachments&attachmentId=${att.attachmentId}">file</a>`;
checkImageAttachments(note, content);
expect(att.save).not.toHaveBeenCalled();
});
});
describe("Markdown content", () => {
it("keeps referenced attachments alive via markdown image syntax", () => {
const note = buildNote({ title: "Test", type: "code", mime: "text/x-markdown" });
const att = makeAttachment(note.noteId);
note.getAttachments = () => [att];
const content = `# Hello\n\n![test](api/attachments/${att.attachmentId}/image/test.png)`;
checkImageAttachments(note, content);
expect(att.save).not.toHaveBeenCalled();
});
it("schedules unreferenced attachments for erasure", () => {
const note = buildNote({ title: "Test", type: "code", mime: "text/x-markdown" });
const att = makeAttachment(note.noteId);
note.getAttachments = () => [att];
checkImageAttachments(note, "# No images\n\nJust text.");
expect(att.save).toHaveBeenCalled();
expect(att.utcDateScheduledForErasureSince).toBeTruthy();
});
it("cancels erasure when attachment is re-referenced", () => {
const note = buildNote({ title: "Test", type: "code", mime: "text/x-markdown" });
const att = makeAttachment(note.noteId, { scheduledForErasure: true });
note.getAttachments = () => [att];
const content = `![img](api/attachments/${att.attachmentId}/image/test.png)`;
checkImageAttachments(note, content);
expect(att.save).toHaveBeenCalled();
expect(att.utcDateScheduledForErasureSince).toBeNull();
});
it("detects attachment IDs in markdown link syntax", () => {
const note = buildNote({ title: "Test", type: "code", mime: "text/x-markdown" });
const att = makeAttachment(note.noteId, { role: "file" });
note.getAttachments = () => [att];
const content = `[my file](#root/${note.noteId}?viewMode=attachments&attachmentId=${att.attachmentId})`;
checkImageAttachments(note, content);
expect(att.save).not.toHaveBeenCalled();
});
it("handles multiple attachments in markdown content", () => {
const note = buildNote({ title: "Test", type: "code", mime: "text/x-markdown" });
const att1 = makeAttachment(note.noteId);
const att2 = makeAttachment(note.noteId);
const att3 = makeAttachment(note.noteId);
note.getAttachments = () => [att1, att2, att3];
const content = [
`![img1](api/attachments/${att1.attachmentId}/image/a.png)`,
"Some text",
`![img2](api/attachments/${att2.attachmentId}/image/b.png)`
].join("\n");
checkImageAttachments(note, content);
expect(att1.save).not.toHaveBeenCalled();
expect(att2.save).not.toHaveBeenCalled();
expect(att3.save).toHaveBeenCalled();
expect(att3.utcDateScheduledForErasureSince).toBeTruthy();
});
});
describe("foreign attachment copying", () => {
it("replaces foreign attachment IDs in HTML content", () => {
const note = buildNote({ title: "Test" });
const foreignNote = buildNote({ title: "Foreign" });
const foreignAtt = makeAttachment(foreignNote.noteId, { id: "foreignAtt1" });
foreignAtt.copy = () => {
const copy = makeAttachment(note.noteId);
copy.blobId = foreignAtt.blobId;
return copy;
};
foreignAtt.getContent = () => Buffer.from("image data");
note.getAttachments = () => [];
becca.getAttachments = vi.fn().mockReturnValue([foreignAtt]);
const content = `<img src="api/attachments/foreignAtt1/image/test.png">`;
const result = checkImageAttachments(note, content);
expect(result.forceFrontendReload).toBe(true);
expect(result.content).not.toContain("foreignAtt1");
});
it("replaces foreign attachment IDs in markdown content", () => {
const note = buildNote({ title: "Test", type: "code", mime: "text/x-markdown" });
const foreignNote = buildNote({ title: "Foreign" });
const foreignAtt = makeAttachment(foreignNote.noteId, { id: "foreignAtt2" });
foreignAtt.copy = () => {
const copy = makeAttachment(note.noteId);
copy.blobId = foreignAtt.blobId;
return copy;
};
foreignAtt.getContent = () => Buffer.from("image data");
note.getAttachments = () => [];
becca.getAttachments = vi.fn().mockReturnValue([foreignAtt]);
const content = `![test](api/attachments/foreignAtt2/image/test.png)`;
const result = checkImageAttachments(note, content);
expect(result.forceFrontendReload).toBe(true);
expect(result.content).not.toContain("foreignAtt2");
});
});
});
+22 -9
View File
@@ -387,18 +387,28 @@ function protectNote(note: BNote, protect: boolean) {
}
}
function checkImageAttachments(note: BNote, content: string) {
export function checkImageAttachments(note: BNote, content: string) {
const foundAttachmentIds = new Set<string>();
let match;
const imgRegExp = /src="[^"]*api\/attachments\/([a-zA-Z0-9_]+)\/image/g;
while ((match = imgRegExp.exec(content))) {
foundAttachmentIds.add(match[1]);
}
const patterns = note.isMarkdown()
? [
// ![...](api/attachments/{id}/image/...)
/!\[[^\]]*\]\([^)]*api\/attachments\/([a-zA-Z0-9_]+)\/image/g,
// [...](#root/{noteId}?viewMode=attachments&attachmentId={id})
/\[[^\]]*\]\([^)]+attachmentId=([a-zA-Z0-9_]+)/g
]
: [
// <img src="api/attachments/{id}/image/...">
/src="[^"]*api\/attachments\/([a-zA-Z0-9_]+)\/image/g,
// <a href="...attachmentId={id}">
/href="[^"]+attachmentId=([a-zA-Z0-9_]+)/g
];
const linkRegExp = /href="[^"]+attachmentId=([a-zA-Z0-9_]+)/g;
while ((match = linkRegExp.exec(content))) {
foundAttachmentIds.add(match[1]);
for (const pattern of patterns) {
while ((match = pattern.exec(content))) {
foundAttachmentIds.add(match[1]);
}
}
const attachments = note.getAttachments();
@@ -745,8 +755,9 @@ function saveAttachments(note: BNote, content: string) {
return content;
}
function saveLinks(note: BNote, content: string | Buffer) {
if ((note.type !== "text" && note.type !== "relationMap") || (note.isProtected && !protectedSessionService.isProtectedSessionAvailable())) {
if ((note.type !== "text" && note.type !== "relationMap" && !note.isMarkdown()) || (note.isProtected && !protectedSessionService.isProtectedSessionAvailable())) {
return {
forceFrontendReload: false,
content
@@ -765,6 +776,8 @@ function saveLinks(note: BNote, content: string | Buffer) {
content = findIncludeNoteLinks(content, foundLinks);
saveBookmarks(note, content);
({ forceFrontendReload, content } = checkImageAttachments(note, content));
} else if (note.isMarkdown() && typeof content === "string") {
({ forceFrontendReload, content } = checkImageAttachments(note, content));
} else if (note.type === "relationMap" && typeof content === "string") {
findRelationMapLinks(content, foundLinks);
@@ -127,6 +127,7 @@ export function buildNote(noteDef: NoteDefinition) {
allAttachments.push(attachment);
}
note.getAttachments = () => allAttachments;
note.getAttachmentsByRole = (role) => allAttachments.filter(a => a.role === role);
}