mirror of
https://github.com/zadam/trilium.git
synced 2026-09-14 11:06:04 +05:00
refactor(export): stop writing derived link relations to the note meta
The importer discards `internalLink` and `imageLink` and lets `saveLinks()` rebuild them from the note's content, a skip that dates to 2019. Exporting them therefore writes a set nothing reads back, and writes it in an order that disagrees with itself: the editor appends a relation for a link inserted mid-note, while a re-derived note lists them in content order, so a synced `!!!meta.json` alternated between the two. They are 1478 of the 2026 attributes in the User Guide export, about 10,000 of its 22,900 lines. `includeNoteLink` and `relationMapLink` stay — `services/import/zip.ts` reads those to remap note ids inside the content it imports. Covered both ways: the meta keeps the relations the importer consumes and drops the two it does not, and a subtree exported and imported back under fresh ids comes back with both relations rebuilt and pointing at the imported notes. That older exports still carry them is already covered on the import side. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4ecc8dd324
commit
c5ac2eb41f
@ -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: "<p>t</p>" });
|
||||
const { note } = createNote(parent.noteId, { title: "Linker", content: "<p>x</p>" });
|
||||
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: "<p>t</p>" });
|
||||
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(
|
||||
`<p>See <a href="#root/${target.noteId}">Target</a>.</p>`
|
||||
+ `<p><img src="api/images/${picture.noteId}/Picture.png"></p>`
|
||||
));
|
||||
|
||||
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: "<p>kept</p>" });
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user