mirror of
https://github.com/zadam/trilium.git
synced 2026-05-11 23:29:45 -05:00
feat(markdown): mark unused attachments to be deleted
This commit is contained in:
@@ -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/"));
|
||||
|
||||
@@ -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`;
|
||||
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 = ``;
|
||||
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 = [
|
||||
``,
|
||||
"Some text",
|
||||
``
|
||||
].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 = ``;
|
||||
const result = checkImageAttachments(note, content);
|
||||
|
||||
expect(result.forceFrontendReload).toBe(true);
|
||||
expect(result.content).not.toContain("foreignAtt2");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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\/([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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user