@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.
- package/README.md +369 -367
- package/core/src/mapping.mjs +325 -300
- package/core/src/og-markdown.mjs +67 -0
- package/core/src/sync-engine.mjs +460 -449
- package/package.json +2 -2
- package/watcher/bin/logseq-sync.mjs +918 -916
package/core/src/mapping.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
files.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
+
}
|