diff --git a/packages/trilium-core/src/services/export/zip.spec.ts b/packages/trilium-core/src/services/export/zip.spec.ts index 5d1ca9a34c..efcda99cb4 100644 --- a/packages/trilium-core/src/services/export/zip.spec.ts +++ b/packages/trilium-core/src/services/export/zip.spec.ts @@ -239,6 +239,65 @@ describe.skipIf(isBrowserRuntime)("zip export (real DB)", () => { expect(attrs.some((a) => a.name === "outsideRel")).toBe(false); }); + it("leaves out the link relations the importer rebuilds, and keeps the ones it reads back", async () => { + // Both notes go inside the exported subtree: a relation pointing out of it is dropped + // by the containment filter, which would hide what this test is about. + const { note: parent } = createNote("root", { title: "LinkHost", content: "" }); + const { note: target } = createNote(parent.noteId, { title: "LinkTarget", content: "

t

" }); + const { note } = createNote(parent.noteId, { title: "Linker", content: "

x

" }); + getContext().init(() => { + note.addRelation("internalLink", target.noteId); + note.addRelation("imageLink", target.noteId); + // The importer reads these two back to remap note ids inside the content. + note.addRelation("includeNoteLink", target.noteId); + note.addRelation("relationMapLink", target.noteId); + note.addRelation("userRelation", target.noteId); + }); + + const { entries } = await exportSubtree(parent.getParentBranches()[0], "html"); + const children = parseMeta(entries).files[0].children ?? []; + const linkerMeta = children.find((child) => child.title === "Linker"); + const names = (linkerMeta?.attributes ?? []).map((a) => a.name); + + expect(names).not.toContain("internalLink"); + expect(names).not.toContain("imageLink"); + expect(names).toContain("includeNoteLink"); + expect(names).toContain("relationMapLink"); + expect(names).toContain("userRelation"); + }); + + it("comes back with those relations anyway, rebuilt from content and pointing at the new notes", async () => { + const { note: parent } = createNote("root", { title: "LinkRoundTrip", content: "" }); + const { note: target } = createNote(parent.noteId, { title: "Target", content: "

t

" }); + const { note: picture } = createNote(parent.noteId, + { title: "Picture", content: "png-bytes", type: "image", mime: "image/png" }); + const { note: source } = createNote(parent.noteId, { title: "Source", content: "" }); + getContext().init(() => source.setContent( + `

See Target.

` + + `

` + )); + + const taskContext = (await import("../task_context.js")).default; + const importZip = (await import("../import/zip.js")).default; + const { buffer } = await exportSubtree(parent.getParentBranches()[0], "html"); + const imported = await getContext().init(async () => await importZip.importZip( + new taskContext("no-progress-reporting", "importNotes", {}), + buffer, + becca.getNoteOrThrow("root") + )); + + const childByTitle = (title: string) => + imported.getChildNotes().find((child) => child.title === title); + const importedSource = childByTitle("Source"); + const relationTargets = (name: string) => + (importedSource?.getRelations() ?? []).filter((rel) => rel.name === name).map((rel) => rel.value); + + // New ids on the far side, so a relation copied out of the export could not have pointed here. + expect(childByTitle("Target")?.noteId).not.toBe(target.noteId); + expect(relationTargets("internalLink")).toStrictEqual([childByTitle("Target")?.noteId]); + expect(relationTargets("imageLink")).toStrictEqual([childByTitle("Picture")?.noteId]); + }); + it("excludes notes marked with #excludeFromExport", async () => { const { note: parent } = createNote("root", { title: "WithExcluded", content: "" }); const { note: kept } = createNote(parent.noteId, { title: "Kept", content: "

kept

" }); diff --git a/packages/trilium-core/src/services/export/zip.ts b/packages/trilium-core/src/services/export/zip.ts index b99653a7d4..ec2cb3194c 100644 --- a/packages/trilium-core/src/services/export/zip.ts +++ b/packages/trilium-core/src/services/export/zip.ts @@ -3,6 +3,7 @@ import sanitize from "sanitize-filename"; import packageInfo from "../../../package.json" with { type: "json" }; import becca from "../../becca/becca.js"; +import type BAttribute from "../../becca/entities/battribute.js"; import BBranch from "../../becca/entities/bbranch.js"; import type BNote from "../../becca/entities/bnote.js"; import dateUtils from "../utils/date.js"; @@ -137,17 +138,19 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, meta.isExpanded = branch.isExpanded; meta.type = note.type; meta.mime = note.mime; - meta.attributes = note.getOwnedAttributes().map((attribute) => { - const attrMeta: AttributeMeta = { - type: attribute.type, - name: attribute.name, - value: attribute.value, - isInheritable: attribute.isInheritable, - position: attribute.position - }; + meta.attributes = note.getOwnedAttributes() + .filter((attribute) => !isRebuiltOnImport(attribute)) + .map((attribute) => { + const attrMeta: AttributeMeta = { + type: attribute.type, + name: attribute.name, + value: attribute.value, + isInheritable: attribute.isInheritable, + position: attribute.position + }; - return attrMeta; - }); + return attrMeta; + }); taskContext.increaseProgressCount(); @@ -514,6 +517,17 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, } } +/** + * Whether the importer discards the attribute and rebuilds it from the note's content, which + * `saveLinks()` does for `internalLink` and `imageLink`. Exporting those writes a set nothing reads + * back, in an order that differs between a note the editor has appended a link to and the same note + * derived on import. `includeNoteLink` and `relationMapLink` stay: `services/import/zip.ts` reads + * them to remap note ids inside the content it imports. + */ +function isRebuiltOnImport(attribute: BAttribute): boolean { + return attribute.type === "relation" && ["internalLink", "imageLink"].includes(attribute.name); +} + /** Counts the notes in a metadata tree — i.e. the number of `saveNote()` calls the content-writing pass will make. */ function countMetaNodes(meta: NoteMeta): number { let count = 1;