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:
Elian Doran 2026-08-22 14:38:32 +03:00
parent 4ecc8dd324
commit c5ac2eb41f
No known key found for this signature in database
2 changed files with 83 additions and 10 deletions

View File

@ -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>" });

View File

@ -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;