@geml/logseq-sync 2.0.7 → 2.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,300 +1,325 @@
1
- // EDN ⇄ GEML for Logseq DB graphs.
2
- //
3
- // Input is what `logseq export-edn` (@logseq/cli) produces — sqlite.build EDN:
4
- //
5
- // {:pages-and-blocks [{:page {...} :blocks [{:block/title ".." :build/children [..]} ..]} ..]
6
- // :properties {..} ; ontology: property definitions
7
- // :classes {..}} ; ontology: class/tag definitions
8
- //
9
- // The mapping keeps two promises, in this order:
10
- //
11
- // 1. LOSSLESS. The round-trip test is EDN → GEML → EDN structural equality
12
- // (EDN map/set semantics: entry order does not count). Anything this
13
- // version does not give a GEML shape of its own rides along VERBATIM as
14
- // EDN inside `code {lang=edn}` blocks — carried, not dropped.
15
- // 2. ADDRESSABLE where it pays. A block's title becomes the body of a
16
- // `=== text` block; a block that has a uuid keeps it as `{#uuid}`, so
17
- // `geml get/set` address exactly the blocks Logseq itself considers
18
- // addressable (uuids are only exported for referenced blocks).
19
- //
20
- // Structure choice: the outline tree is a FLAT sequence of blocks in
21
- // depth-first order, each carrying `level=N` — a complete encoding of the tree
22
- // (it is how outlines print), without nesting GEML fences to the outline's
23
- // depth.
24
- //
25
- // Everything runs on edn-data's TYPED representation (keywords as {key}, sets
26
- // as {set}, maps as {map: [[k,v]..]}, vectors as arrays), so nothing is coerced
27
- // through JSON and nothing un-EDN-able is invented.
28
-
29
- import { parseEDNString, toEDNString } from "edn-data";
30
-
31
- // --- typed-EDN helpers -------------------------------------------------------
32
-
33
- const kw = (name) => ({ key: name });
34
- const isKw = (v, name) => v !== null && typeof v === "object" && v.key === name;
35
- const mapEntries = (m) => (m !== null && typeof m === "object" && Array.isArray(m.map) ? m.map : []);
36
- const mapGet = (m, name) => {
37
- for (const [k, v] of mapEntries(m)) if (isKw(k, name)) return v;
38
- return undefined;
39
- };
40
- const mapWithout = (m, names) => ({ map: mapEntries(m).filter(([k]) => !names.some((n) => isKw(k, n))) });
41
- const mapSize = (m) => mapEntries(m).length;
42
- const edn = (v) => toEDNString(v);
43
-
44
- // edn-data renders `#uuid "..."` as a tagged value; accept both spellings.
45
- const uuidOf = (v) => {
46
- if (typeof v === "string") return v;
47
- if (v && typeof v === "object") {
48
- if (typeof v.uuid === "string") return v.uuid;
49
- if (v.tag === "uuid" && typeof v.val === "string") return v.val;
50
- }
51
- return undefined;
52
- };
53
-
54
- // --- GEML text helpers -------------------------------------------------------
55
-
56
- // A fence must be longer than any `=` run opening a line of the body (§3).
57
- function fenceFor(body) {
58
- let longest = 2;
59
- for (const m of body.matchAll(/^=+/gm)) longest = Math.max(longest, m[0].length + 1);
60
- return "=".repeat(Math.max(3, longest));
61
- }
62
-
63
- function gemlBlock(type, attrs, body) {
64
- const f = fenceFor(body);
65
- const a = attrs ? ` {${attrs}}` : "";
66
- return `${f} ${type}${a}\n${body}\n${f}\n`;
67
- }
68
-
69
- // --- export: EDN → GEML files ------------------------------------------------
70
-
71
- // Returns Map<relativePath, gemlText>. Page order is preserved by a numeric
72
- // filename prefix: :pages-and-blocks is a vector, and order is content.
73
- // A Logseq block reference, as the DB export writes it: `[[<uuid>]]` inside a
74
- // block's title. A PAGE reference looks identical apart from its target
75
- // (`[[Some Page]]`), so the uuid shape is the whole discriminator — matching
76
- // anything looser would rewrite people's page links.
77
- const REF_BARE = /\[\[([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\]\]/g;
78
- // The GEML form, on the way back: `[[#uuid]]` or `[[path/to/doc.geml#uuid]]`.
79
- const REF_GEML = /\[\[([^\[\]]*?)#([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\]\]/g;
80
-
81
- /** POSIX-relative path from one vault file to another, as GEML resolves it. */
82
- function relFromTo(fromPath, toPath) {
83
- const from = fromPath.split("/").slice(0, -1);
84
- const to = toPath.split("/");
85
- let i = 0;
86
- while (i < from.length && i < to.length - 1 && from[i] === to[i]) i++;
87
- return [...from.slice(i).map(() => ".."), ...to.slice(i)].join("/");
88
- }
89
-
90
- /**
91
- * Turn Logseq's unchecked `[[uuid]]` into GEML's checked reference — the whole
92
- * point of the exercise: `geml check` then reports a reference that goes
93
- * nowhere instead of shrugging at it.
94
- *
95
- * A target in the same file becomes `[[#uuid]]`, one in another file
96
- * `[[<relative path>#uuid]]`. A uuid the export never wrote also becomes
97
- * `[[#uuid]]`, which `check` calls unresolved — because within this vault it
98
- * IS: `@logseq/cli` 0.4.3 does not export journal pages, so a ref into one
99
- * genuinely leads nowhere here, and saying so is the promise being kept, not
100
- * broken. Translation is exactly reversible, which is what keeps the round
101
- * trip an identity.
102
- */
103
- export function translateRefsOut(files, uuidPath) {
104
- for (const [path, text] of files) {
105
- const next = text.replace(REF_BARE, (_m, uuid) => {
106
- const target = uuidPath.get(uuid.toLowerCase());
107
- if (!target || target === path) return `[[#${uuid}]]`;
108
- return `[[${relFromTo(path, target)}#${uuid}]]`;
109
- });
110
- if (next !== text) files.set(path, next);
111
- }
112
- return files;
113
- }
114
-
115
- /** The inverse: any `[[…#uuid]]` back to the `[[uuid]]` Logseq stores. */
116
- export function translateRefsIn(text) {
117
- return text.replace(REF_GEML, (_m, _prefix, uuid) => `[[${uuid}]]`);
118
- }
119
-
120
- export function ednToGemlFiles(ednText) {
121
- const top = parseEDNString(ednText);
122
- const files = new Map();
123
-
124
- const pages = mapGet(top, "pages-and-blocks") ?? [];
125
- const properties = mapGet(top, "properties");
126
- const classes = mapGet(top, "classes");
127
- const rest = mapWithout(top, ["pages-and-blocks", "properties", "classes"]);
128
-
129
- // Ontology and any top-level keys this version does not model: verbatim.
130
- let onto = '=== meta\ntitle = "Logseq graph ontology"\n===\n\n';
131
- if (properties !== undefined) onto += gemlBlock("code", "#properties lang=edn", edn(properties));
132
- if (classes !== undefined) onto += gemlBlock("code", "#classes lang=edn", edn(classes));
133
- if (mapSize(rest) > 0) onto += gemlBlock("code", "#graph-extra lang=edn", edn(rest));
134
- files.set("ontology.geml", onto);
135
-
136
- const order = [];
137
- // uuid the file that will hold that block, filled during the walk and used
138
- // once every file exists: a reference can point at a page written later.
139
- const uuidPath = new Map();
140
- pages.forEach((entry) => {
141
- const page = mapGet(entry, "page") ?? { map: [] };
142
- const blocksVal = mapGet(entry, "blocks");
143
- const blocks = blocksVal ?? [];
144
- const entryRest = mapWithout(entry, ["page", "blocks"]);
145
- // A present-but-empty :blocks is not the same EDN as an absent one, and
146
- // real exports write `:blocks []` on block-less pages. An empty vector has
147
- // no text blocks to speak for it, so it rides along with the rest.
148
- if (Array.isArray(blocksVal) && blocksVal.length === 0) entryRest.map.push([kw("blocks"), []]);
149
-
150
- const title = mapGet(page, "block/title");
151
- const journal = mapGet(page, "build/journal");
152
- // The tree is laid out the way an OG vault is: journals under `journals/`
153
- // with the OG date filename (20250220 → 2025_02_20.geml), everything else
154
- // under `pages/` named by the page itself. No numeric prefixes — page
155
- // ORDER is content, but it belongs in the graph.geml index, not in
156
- // filenames a person has to look at.
157
- let path;
158
- if (typeof journal === "number") {
159
- const j = String(journal);
160
- path = `journals/${j.slice(0, 4)}_${j.slice(4, 6)}_${j.slice(6, 8)}.geml`;
161
- } else {
162
- const nameSeed = typeof title === "string" ? title : "page";
163
- const slug = nameSeed.toLowerCase().replace(/[^a-z0-9一-鿿]+/gu, "-").replace(/^-+|-+$/g, "") || "page";
164
- path = `pages/${slug}.geml`;
165
- }
166
- // Two titles may slug identically; the index carries order and identity,
167
- // so filenames only need to be unique.
168
- for (let n = 2; files.has(path); n++) path = path.replace(/\.geml$/, "") .replace(/-\d+$/, "") + `-${n}.geml`;
169
- order.push(path);
170
-
171
- // The page's identity, verbatim reconstruction reads THIS; the heading
172
- // below is presentation, not data.
173
- let out = gemlBlock("code", ".page-meta lang=edn", edn(page));
174
- if (mapSize(entryRest) > 0) out += gemlBlock("code", ".page-extra lang=edn", edn(entryRest));
175
- if (typeof title === "string") out += `\n# ${title}\n\n`;
176
-
177
- const walk = (bs, level) => {
178
- for (const b of bs) {
179
- const btitle = mapGet(b, "block/title");
180
- const children = mapGet(b, "build/children") ?? [];
181
- const meta = mapWithout(b, ["block/title", "build/children"]);
182
- // The uuid stays inside the meta EDN too — losslessness never depends
183
- // on the id attribute; `{#uuid}` is the ADDRESS.
184
- const u = uuidOf(mapGet(b, "block/uuid"));
185
- if (u) uuidPath.set(u.toLowerCase(), path);
186
- const id = u ? `#${u} ` : "";
187
- out += gemlBlock("text", `${id}level=${level}`, typeof btitle === "string" ? btitle : edn(btitle ?? null));
188
- if (mapSize(meta) > 0) out += gemlBlock("code", ".block-meta lang=edn", edn(meta));
189
- walk(children, level + 1);
190
- }
191
- };
192
- walk(blocks, 1);
193
- files.set(path, out);
194
- });
195
-
196
- // Page order is content (:pages-and-blocks is a vector), but it lives in the
197
- // index rather than in filename prefixes: the tree stays human-shaped, and
198
- // one addressable block carries what the machine needs.
199
- files.set("graph.geml",
200
- '=== meta\ntitle = "Logseq graph index"\n===\n\n' +
201
- gemlBlock("data", "#page-order", JSON.stringify(order, null, 1)));
202
-
203
- // Last, because a reference needs to know where every block ended up.
204
- return translateRefsOut(files, uuidPath);
205
- }
206
-
207
- // --- import: GEML files EDN ------------------------------------------------
208
-
209
- // The parser library is injected ({parse, addressedUnits, sliceUnit} from
210
- // @geml/geml), so this module stays dependency-light and the caller decides
211
- // which parser build to trust.
212
- //
213
- // Why two reads per document: `parse` gives structure (types, classes, attrs),
214
- // but a `text` block is FLOW content — its node carries parsed inlines, not
215
- // raw bytes. The bytes come from `sliceUnit` over the block's span, exactly the
216
- // route `geml get` takes. Blocks arrive in document order from both, so the
217
- // two sequences zip.
218
- export function gemlFilesToEdn(filesIn, lib) {
219
- const { parse, addressedUnits, sliceUnit } = lib;
220
- // Checked references go back to the `[[uuid]]` Logseq stores, before any
221
- // parsing: the graph is the other side of the translation, not a party to it.
222
- // Vaults written before the translation existed hold bare uuids already, and
223
- // this leaves those alone the same import handles both.
224
- const files = new Map([...filesIn].map(([path, text]) => [path, translateRefsIn(text)]));
225
- const blocksOf = (text) => {
226
- const nodes = parse(text).children.filter((c) => c.kind === "block");
227
- const units = [...addressedUnits(text)].map((a) => a.unit).filter((u) => u.kind === "block");
228
- return nodes.map((node, i) => ({
229
- node,
230
- body: () => {
231
- const s = sliceUnit(text, units[i].span, "body");
232
- return s.endsWith("\n") ? s.slice(0, -1) : s;
233
- },
234
- }));
235
- };
236
-
237
- const onto = blocksOf(files.get("ontology.geml") ?? "");
238
- const grab = (blocks, id) => {
239
- const b = blocks.find((x) => x.node.id === id);
240
- return b ? parseEDNString(b.body()) : undefined;
241
- };
242
- const properties = grab(onto, "properties");
243
- const classes = grab(onto, "classes");
244
- const graphExtra = grab(onto, "graph-extra");
245
-
246
- // Page order comes from the graph.geml index; a tree without one (hand-built,
247
- // or index deleted) falls back to path order, which at least is deterministic.
248
- const indexBlocks = files.has("graph.geml") ? blocksOf(files.get("graph.geml")) : [];
249
- const orderBlock = indexBlocks.find((b) => b.node.id === "page-order");
250
- const pagePaths = (orderBlock && Array.isArray(orderBlock.node.value)
251
- ? orderBlock.node.value
252
- : [...files.keys()].filter((p) => p.startsWith("pages/") || p.startsWith("journals/")).sort()
253
- ).filter((p) => files.has(p));
254
- const pages = pagePaths.map((p) => {
255
- let page = { map: [] };
256
- let entryRest = { map: [] };
257
-
258
- // Flat level-tagged sequence → tree. Each frame owns the children vector
259
- // its node's `:build/children` will become; the vector is written into the
260
- // node only if anything landed in it.
261
- const roots = [];
262
- const stack = [{ level: 0, node: null, children: roots }];
263
- const close = (frame) => {
264
- if (frame.node && frame.children.length > 0) frame.node.map.push([kw("build/children"), frame.children]);
265
- };
266
-
267
- let last = null;
268
- for (const b of blocksOf(files.get(p))) {
269
- const { type, classes, attrs } = b.node;
270
- if (type === "code" && classes.includes("page-meta")) { page = parseEDNString(b.body()); continue; }
271
- if (type === "code" && classes.includes("page-extra")) { entryRest = parseEDNString(b.body()); continue; }
272
- if (type === "code" && classes.includes("block-meta")) {
273
- // Meta re-attaches to the block it followed. Splicing the entries into
274
- // the node keeps one map, as the export wrote it.
275
- if (last) last.map.push(...mapEntries(parseEDNString(b.body())));
276
- continue;
277
- }
278
- if (type !== "text") continue;
279
-
280
- const level = typeof attrs["level"] === "number" ? attrs["level"] : 1;
281
- const node = { map: [[kw("block/title"), b.body()]] };
282
- while (stack[stack.length - 1].level >= level) close(stack.pop());
283
- stack[stack.length - 1].children.push(node);
284
- stack.push({ level, node, children: [] });
285
- last = node;
286
- }
287
- while (stack.length > 1) close(stack.pop());
288
-
289
- const entry = { map: [[kw("page"), page]] };
290
- if (roots.length > 0) entry.map.push([kw("blocks"), roots]);
291
- entry.map.push(...mapEntries(entryRest));
292
- return entry;
293
- });
294
-
295
- const out = { map: [[kw("pages-and-blocks"), pages]] };
296
- if (properties !== undefined) out.map.push([kw("properties"), properties]);
297
- if (classes !== undefined) out.map.push([kw("classes"), classes]);
298
- if (graphExtra !== undefined) out.map.push(...mapEntries(graphExtra));
299
- return toEDNString(out);
300
- }
1
+ // EDN ⇄ GEML for Logseq DB graphs.
2
+ //
3
+ // Input is what `logseq export-edn` (@logseq/cli) produces — sqlite.build EDN:
4
+ //
5
+ // {:pages-and-blocks [{:page {...} :blocks [{:block/title ".." :build/children [..]} ..]} ..]
6
+ // :properties {..} ; ontology: property definitions
7
+ // :classes {..}} ; ontology: class/tag definitions
8
+ //
9
+ // The mapping keeps two promises, in this order:
10
+ //
11
+ // 1. LOSSLESS. The round-trip test is EDN → GEML → EDN structural equality
12
+ // (EDN map/set semantics: entry order does not count). Anything this
13
+ // version does not give a GEML shape of its own rides along VERBATIM as
14
+ // EDN inside `code {lang=edn}` blocks — carried, not dropped.
15
+ // 2. ADDRESSABLE where it pays. A block's title becomes the body of a
16
+ // `=== text` block; a block that has a uuid keeps it as `{#uuid}`, so
17
+ // `geml get/set` address exactly the blocks Logseq itself considers
18
+ // addressable (uuids are only exported for referenced blocks).
19
+ //
20
+ // Structure choice: the outline tree is a FLAT sequence of blocks in
21
+ // depth-first order, each carrying `.level-N` — a complete encoding of the tree
22
+ // (it is how outlines print), without nesting GEML fences to the outline's
23
+ // depth.
24
+ //
25
+ // Everything runs on edn-data's TYPED representation (keywords as {key}, sets
26
+ // as {set}, maps as {map: [[k,v]..]}, vectors as arrays), so nothing is coerced
27
+ // through JSON and nothing un-EDN-able is invented.
28
+
29
+ import { parseEDNString, toEDNString } from "edn-data";
30
+
31
+ // --- typed-EDN helpers -------------------------------------------------------
32
+
33
+ const kw = (name) => ({ key: name });
34
+ const isKw = (v, name) => v !== null && typeof v === "object" && v.key === name;
35
+ const mapEntries = (m) => (m !== null && typeof m === "object" && Array.isArray(m.map) ? m.map : []);
36
+ const mapGet = (m, name) => {
37
+ for (const [k, v] of mapEntries(m)) if (isKw(k, name)) return v;
38
+ return undefined;
39
+ };
40
+ const mapWithout = (m, names) => ({ map: mapEntries(m).filter(([k]) => !names.some((n) => isKw(k, n))) });
41
+ const mapSize = (m) => mapEntries(m).length;
42
+ const edn = (v) => toEDNString(v);
43
+
44
+ // edn-data renders `#uuid "..."` as a tagged value; accept both spellings.
45
+ const uuidOf = (v) => {
46
+ if (typeof v === "string") return v;
47
+ if (v && typeof v === "object") {
48
+ if (typeof v.uuid === "string") return v.uuid;
49
+ if (v.tag === "uuid" && typeof v.val === "string") return v.val;
50
+ }
51
+ return undefined;
52
+ };
53
+
54
+ // --- GEML text helpers -------------------------------------------------------
55
+
56
+ // A fence must be longer than any `=` run opening a line of the body (§3).
57
+ function fenceFor(body) {
58
+ let longest = 2;
59
+ for (const m of body.matchAll(/^=+/gm)) longest = Math.max(longest, m[0].length + 1);
60
+ return "=".repeat(Math.max(3, longest));
61
+ }
62
+
63
+ function gemlBlock(type, attrs, body) {
64
+ const f = fenceFor(body);
65
+ const a = attrs ? ` {${attrs}}` : "";
66
+ return `${f} ${type}${a}\n${body}\n${f}\n`;
67
+ }
68
+
69
+ // --- export: EDN → GEML files ------------------------------------------------
70
+
71
+ // Returns Map<relativePath, gemlText>. Page order is preserved by a numeric
72
+ // filename prefix: :pages-and-blocks is a vector, and order is content.
73
+ // A Logseq block reference, as the DB export writes it: `[[<uuid>]]` inside a
74
+ // block's title. A PAGE reference looks identical apart from its target
75
+ // (`[[Some Page]]`), so the uuid shape is the whole discriminator — matching
76
+ // anything looser would rewrite people's page links.
77
+ const REF_BARE = /\[\[([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\]\]/g;
78
+ // The GEML form, on the way back: `[[#uuid]]` or `[[path/to/doc.geml#uuid]]`.
79
+ const REF_GEML = /\[\[([^\[\]]*?)#([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\]\]/g;
80
+
81
+ /** POSIX-relative path from one vault file to another, as GEML resolves it. */
82
+ function relFromTo(fromPath, toPath) {
83
+ const from = fromPath.split("/").slice(0, -1);
84
+ const to = toPath.split("/");
85
+ let i = 0;
86
+ while (i < from.length && i < to.length - 1 && from[i] === to[i]) i++;
87
+ return [...from.slice(i).map(() => ".."), ...to.slice(i)].join("/");
88
+ }
89
+
90
+ /**
91
+ * Turn Logseq's unchecked `[[uuid]]` into GEML's checked reference — the whole
92
+ * point of the exercise: `geml check` then reports a reference that goes
93
+ * nowhere instead of shrugging at it.
94
+ *
95
+ * A target in the same file becomes `[[#uuid]]`, one in another file
96
+ * `[[<relative path>#uuid]]`. A uuid the export never wrote also becomes
97
+ * `[[#uuid]]`, which `check` calls unresolved — because within this vault it
98
+ * IS: `@logseq/cli` 0.4.3 does not export journal pages, so a ref into one
99
+ * genuinely leads nowhere here, and saying so is the promise being kept, not
100
+ * broken. Translation is exactly reversible, which is what keeps the round
101
+ * trip an identity.
102
+ */
103
+ export function translateRefsOut(files, uuidPath) {
104
+ for (const [path, text] of files) {
105
+ const next = text.replace(REF_BARE, (_m, uuid) => {
106
+ const target = uuidPath.get(uuid.toLowerCase());
107
+ if (!target || target === path) return `[[#${uuid}]]`;
108
+ return `[[${relFromTo(path, target)}#${uuid}]]`;
109
+ });
110
+ if (next !== text) files.set(path, next);
111
+ }
112
+ return files;
113
+ }
114
+
115
+ /** The inverse: any `[[…#uuid]]` back to the `[[uuid]]` Logseq stores. */
116
+ export function translateRefsIn(text) {
117
+ return text.replace(REF_GEML, (_m, _prefix, uuid) => `[[${uuid}]]`);
118
+ }
119
+
120
+ // Outline depth rides on a CLASS (`.level-3`), not an attribute. It has to ride
121
+ // somewhere: Logseq's blocks are a tree (`:build/children`), a GEML document's
122
+ // top level is a flat sequence, and the depth is the whole encoding of the tree
123
+ // — the import below rebuilds `:build/children` from it. As an attribute it was
124
+ // `level=3`, which drew `unknown attribute 'level' for block type 'text'` from
125
+ // `geml check` on EVERY block: a wall of warnings on a vault the README tells
126
+ // people to check, and noise is how a check loses its authority. Classes are
127
+ // free-form by design, so `.level-3` says the same thing silently.
128
+ //
129
+ // Vaults written before this carry `level=N`; the reader below accepts both, so
130
+ // an older vault still imports. The first sync after upgrading rewrites every
131
+ // block's head line one real diff, once.
132
+ const LEVEL_CLASS = /^level-(\d+)$/;
133
+ function levelOf(classes, attrs) {
134
+ for (const c of classes ?? []) {
135
+ const m = LEVEL_CLASS.exec(c);
136
+ if (m) {
137
+ const n = Number(m[1]);
138
+ if (Number.isInteger(n) && n >= 1) return n;
139
+ }
140
+ }
141
+ // Pre-class vaults, and anything that lost its depth: treat as a root block.
142
+ return typeof attrs?.["level"] === "number" ? attrs["level"] : 1;
143
+ }
144
+
145
+ export function ednToGemlFiles(ednText) {
146
+ const top = parseEDNString(ednText);
147
+ const files = new Map();
148
+
149
+ const pages = mapGet(top, "pages-and-blocks") ?? [];
150
+ const properties = mapGet(top, "properties");
151
+ const classes = mapGet(top, "classes");
152
+ const rest = mapWithout(top, ["pages-and-blocks", "properties", "classes"]);
153
+
154
+ // Ontology and any top-level keys this version does not model: verbatim.
155
+ let onto = '=== meta\ntitle = "Logseq graph ontology"\n===\n\n';
156
+ if (properties !== undefined) onto += gemlBlock("code", "#properties lang=edn", edn(properties));
157
+ if (classes !== undefined) onto += gemlBlock("code", "#classes lang=edn", edn(classes));
158
+ if (mapSize(rest) > 0) onto += gemlBlock("code", "#graph-extra lang=edn", edn(rest));
159
+ files.set("ontology.geml", onto);
160
+
161
+ const order = [];
162
+ // uuid the file that will hold that block, filled during the walk and used
163
+ // once every file exists: a reference can point at a page written later.
164
+ const uuidPath = new Map();
165
+ pages.forEach((entry) => {
166
+ const page = mapGet(entry, "page") ?? { map: [] };
167
+ const blocksVal = mapGet(entry, "blocks");
168
+ const blocks = blocksVal ?? [];
169
+ const entryRest = mapWithout(entry, ["page", "blocks"]);
170
+ // A present-but-empty :blocks is not the same EDN as an absent one, and
171
+ // real exports write `:blocks []` on block-less pages. An empty vector has
172
+ // no text blocks to speak for it, so it rides along with the rest.
173
+ if (Array.isArray(blocksVal) && blocksVal.length === 0) entryRest.map.push([kw("blocks"), []]);
174
+
175
+ const title = mapGet(page, "block/title");
176
+ const journal = mapGet(page, "build/journal");
177
+ // The tree is laid out the way an OG vault is: journals under `journals/`
178
+ // with the OG date filename (20250220 2025_02_20.geml), everything else
179
+ // under `pages/` named by the page itself. No numeric prefixes — page
180
+ // ORDER is content, but it belongs in the graph.geml index, not in
181
+ // filenames a person has to look at.
182
+ let path;
183
+ if (typeof journal === "number") {
184
+ const j = String(journal);
185
+ path = `journals/${j.slice(0, 4)}_${j.slice(4, 6)}_${j.slice(6, 8)}.geml`;
186
+ } else {
187
+ const nameSeed = typeof title === "string" ? title : "page";
188
+ const slug = nameSeed.toLowerCase().replace(/[^a-z0-9一-鿿]+/gu, "-").replace(/^-+|-+$/g, "") || "page";
189
+ path = `pages/${slug}.geml`;
190
+ }
191
+ // Two titles may slug identically; the index carries order and identity,
192
+ // so filenames only need to be unique.
193
+ for (let n = 2; files.has(path); n++) path = path.replace(/\.geml$/, "") .replace(/-\d+$/, "") + `-${n}.geml`;
194
+ order.push(path);
195
+
196
+ // The page's identity, verbatim reconstruction reads THIS; the heading
197
+ // below is presentation, not data.
198
+ let out = gemlBlock("code", ".page-meta lang=edn", edn(page));
199
+ if (mapSize(entryRest) > 0) out += gemlBlock("code", ".page-extra lang=edn", edn(entryRest));
200
+ if (typeof title === "string") out += `\n# ${title}\n\n`;
201
+
202
+ const walk = (bs, level) => {
203
+ for (const b of bs) {
204
+ const btitle = mapGet(b, "block/title");
205
+ const children = mapGet(b, "build/children") ?? [];
206
+ const meta = mapWithout(b, ["block/title", "build/children"]);
207
+ // The uuid stays inside the meta EDN too — losslessness never depends
208
+ // on the id attribute; `{#uuid}` is the ADDRESS.
209
+ const u = uuidOf(mapGet(b, "block/uuid"));
210
+ if (u) uuidPath.set(u.toLowerCase(), path);
211
+ const id = u ? `#${u} ` : "";
212
+ out += gemlBlock("text", `${id}.level-${level}`, typeof btitle === "string" ? btitle : edn(btitle ?? null));
213
+ if (mapSize(meta) > 0) out += gemlBlock("code", ".block-meta lang=edn", edn(meta));
214
+ walk(children, level + 1);
215
+ }
216
+ };
217
+ walk(blocks, 1);
218
+ files.set(path, out);
219
+ });
220
+
221
+ // Page order is content (:pages-and-blocks is a vector), but it lives in the
222
+ // index rather than in filename prefixes: the tree stays human-shaped, and
223
+ // one addressable block carries what the machine needs.
224
+ files.set("graph.geml",
225
+ '=== meta\ntitle = "Logseq graph index"\n===\n\n' +
226
+ gemlBlock("data", "#page-order", JSON.stringify(order, null, 1)));
227
+
228
+ // Last, because a reference needs to know where every block ended up.
229
+ return translateRefsOut(files, uuidPath);
230
+ }
231
+
232
+ // --- import: GEML files EDN ------------------------------------------------
233
+
234
+ // The parser library is injected ({parse, addressedUnits, sliceUnit} from
235
+ // @geml/geml), so this module stays dependency-light and the caller decides
236
+ // which parser build to trust.
237
+ //
238
+ // Why two reads per document: `parse` gives structure (types, classes, attrs),
239
+ // but a `text` block is FLOW content — its node carries parsed inlines, not
240
+ // raw bytes. The bytes come from `sliceUnit` over the block's span, exactly the
241
+ // route `geml get` takes. Blocks arrive in document order from both, so the
242
+ // two sequences zip.
243
+ export function gemlFilesToEdn(filesIn, lib) {
244
+ const { parse, addressedUnits, sliceUnit } = lib;
245
+ // Checked references go back to the `[[uuid]]` Logseq stores, before any
246
+ // parsing: the graph is the other side of the translation, not a party to it.
247
+ // Vaults written before the translation existed hold bare uuids already, and
248
+ // this leaves those alone the same import handles both.
249
+ const files = new Map([...filesIn].map(([path, text]) => [path, translateRefsIn(text)]));
250
+ const blocksOf = (text) => {
251
+ const nodes = parse(text).children.filter((c) => c.kind === "block");
252
+ const units = [...addressedUnits(text)].map((a) => a.unit).filter((u) => u.kind === "block");
253
+ return nodes.map((node, i) => ({
254
+ node,
255
+ body: () => {
256
+ const s = sliceUnit(text, units[i].span, "body");
257
+ return s.endsWith("\n") ? s.slice(0, -1) : s;
258
+ },
259
+ }));
260
+ };
261
+
262
+ const onto = blocksOf(files.get("ontology.geml") ?? "");
263
+ const grab = (blocks, id) => {
264
+ const b = blocks.find((x) => x.node.id === id);
265
+ return b ? parseEDNString(b.body()) : undefined;
266
+ };
267
+ const properties = grab(onto, "properties");
268
+ const classes = grab(onto, "classes");
269
+ const graphExtra = grab(onto, "graph-extra");
270
+
271
+ // Page order comes from the graph.geml index; a tree without one (hand-built,
272
+ // or index deleted) falls back to path order, which at least is deterministic.
273
+ const indexBlocks = files.has("graph.geml") ? blocksOf(files.get("graph.geml")) : [];
274
+ const orderBlock = indexBlocks.find((b) => b.node.id === "page-order");
275
+ const pagePaths = (orderBlock && Array.isArray(orderBlock.node.value)
276
+ ? orderBlock.node.value
277
+ : [...files.keys()].filter((p) => p.startsWith("pages/") || p.startsWith("journals/")).sort()
278
+ ).filter((p) => files.has(p));
279
+ const pages = pagePaths.map((p) => {
280
+ let page = { map: [] };
281
+ let entryRest = { map: [] };
282
+
283
+ // Flat level-tagged sequence → tree. Each frame owns the children vector
284
+ // its node's `:build/children` will become; the vector is written into the
285
+ // node only if anything landed in it.
286
+ const roots = [];
287
+ const stack = [{ level: 0, node: null, children: roots }];
288
+ const close = (frame) => {
289
+ if (frame.node && frame.children.length > 0) frame.node.map.push([kw("build/children"), frame.children]);
290
+ };
291
+
292
+ let last = null;
293
+ for (const b of blocksOf(files.get(p))) {
294
+ const { type, classes, attrs } = b.node;
295
+ if (type === "code" && classes.includes("page-meta")) { page = parseEDNString(b.body()); continue; }
296
+ if (type === "code" && classes.includes("page-extra")) { entryRest = parseEDNString(b.body()); continue; }
297
+ if (type === "code" && classes.includes("block-meta")) {
298
+ // Meta re-attaches to the block it followed. Splicing the entries into
299
+ // the node keeps one map, as the export wrote it.
300
+ if (last) last.map.push(...mapEntries(parseEDNString(b.body())));
301
+ continue;
302
+ }
303
+ if (type !== "text") continue;
304
+
305
+ const level = levelOf(classes, attrs);
306
+ const node = { map: [[kw("block/title"), b.body()]] };
307
+ while (stack[stack.length - 1].level >= level) close(stack.pop());
308
+ stack[stack.length - 1].children.push(node);
309
+ stack.push({ level, node, children: [] });
310
+ last = node;
311
+ }
312
+ while (stack.length > 1) close(stack.pop());
313
+
314
+ const entry = { map: [[kw("page"), page]] };
315
+ if (roots.length > 0) entry.map.push([kw("blocks"), roots]);
316
+ entry.map.push(...mapEntries(entryRest));
317
+ return entry;
318
+ });
319
+
320
+ const out = { map: [[kw("pages-and-blocks"), pages]] };
321
+ if (properties !== undefined) out.map.push([kw("properties"), properties]);
322
+ if (classes !== undefined) out.map.push([kw("classes"), classes]);
323
+ if (graphExtra !== undefined) out.map.push(...mapEntries(graphExtra));
324
+ return toEDNString(out);
325
+ }