@geml/logseq-sync 2.0.6 → 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,449 +1,460 @@
1
- // Core Sync Engine for Logseq GEML
2
- // Keeps a local folder of .geml files in sync with a Logseq DB graph,
3
- // ensuring only changed files are written so Git diffs stay clean.
4
-
5
- import {
6
- readdirSync,
7
- readFileSync,
8
- writeFileSync,
9
- unlinkSync,
10
- mkdirSync,
11
- rmdirSync,
12
- renameSync,
13
- existsSync,
14
- } from "node:fs";
15
- import { join, dirname, relative, resolve, sep, isAbsolute } from "node:path";
16
- import { execFileSync } from "node:child_process";
17
- import { randomUUID, createHash } from "node:crypto";
18
- import { ednToGemlFiles, gemlFilesToEdn } from "./mapping.mjs";
19
-
20
- const MANIFEST_FILE = ".geml-manifest.json";
21
-
22
- const sha256 = (s) => createHash("sha256").update(s).digest("hex");
23
-
24
- /**
25
- * Read the sync manifest in either of its two shapes.
26
- * v1 was a sorted array of paths enough to know which files the sync owns.
27
- * v2 ({ version: 2, files: { rel: sha256 } }) also records the content the
28
- * sync last wrote or saw, which is what lets two-way sync tell an external
29
- * edit from its own echo: a file whose hash matches the manifest is the
30
- * watcher's own last write, not something a person or agent changed.
31
- * @returns {{ known: boolean, hashed: boolean, files: Map<string, string|null> }}
32
- */
33
- function readManifest(targetDir) {
34
- const manifestPath = join(targetDir, MANIFEST_FILE);
35
- if (!existsSync(manifestPath)) return { known: false, hashed: false, files: new Map() };
36
- try {
37
- const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
38
- if (Array.isArray(parsed)) {
39
- return { known: true, hashed: false, files: new Map(parsed.map((p) => [p, null])) };
40
- }
41
- if (parsed && parsed.version === 2 && parsed.files && typeof parsed.files === "object") {
42
- return { known: true, hashed: true, files: new Map(Object.entries(parsed.files)) };
43
- }
44
- } catch {}
45
- return { known: false, hashed: false, files: new Map() };
46
- }
47
-
48
- /**
49
- * What changed in the vault since the sync last touched it — the read side of
50
- * the two-way bridge. Baselines come from the v2 manifest hashes; a v1
51
- * manifest (or none) knows which files exist but not what they held, so it
52
- * reports nothing rather than guessing: `baselineKnown: false` means "sync
53
- * once first".
54
- *
55
- * With `graphFiles` (the current export, as ednToGemlFiles returns it) the
56
- * vault-modified files are split further: one the GRAPH also moved since the
57
- * last sync is a `conflict` importing it would clobber the graph's edit,
58
- * exporting over it would clobber the person's, so two-way sync does neither
59
- * and a person merges.
60
- * @param {string} targetDir
61
- * @param {{ graphFiles?: Map<string, string> }} [opts]
62
- * @returns {{ baselineKnown: boolean, modified: string[], added: string[], missing: string[], conflicts: string[] }}
63
- */
64
- export function detectExternalEdits(targetDir, opts = {}) {
65
- const manifest = readManifest(targetDir);
66
- const onDisk = readGemlFilesFromDisk(targetDir);
67
- const modified = [];
68
- const added = [];
69
- const missing = [];
70
- const conflicts = [];
71
- if (!manifest.hashed) return { baselineKnown: false, modified, added, missing, conflicts };
72
- for (const [rel, hash] of manifest.files) {
73
- const content = onDisk.get(rel);
74
- if (content === undefined) missing.push(rel);
75
- else if (hash !== null && sha256(content) !== hash) {
76
- const graphContent = opts.graphFiles?.get(rel);
77
- const graphMoved =
78
- graphContent !== undefined && sha256(normalizeEol(graphContent)) !== hash;
79
- (graphMoved ? conflicts : modified).push(rel);
80
- }
81
- }
82
- for (const rel of onDisk.keys()) {
83
- if (!manifest.files.has(rel)) added.push(rel);
84
- }
85
- return {
86
- baselineKnown: true,
87
- modified: modified.sort(),
88
- added: added.sort(),
89
- missing: missing.sort(),
90
- conflicts: conflicts.sort(),
91
- };
92
- }
93
-
94
- /**
95
- * Normalize line endings to LF, handling CRLF (\r\n) and lone CR (\r).
96
- */
97
- export function normalizeEol(str) {
98
- return typeof str === "string" ? str.replace(/\r\n?/g, "\n") : str;
99
- }
100
-
101
- /**
102
- * Atomically write a file via a temporary file in the same directory.
103
- */
104
- export function atomicWriteFileSync(filePath, content) {
105
- const dir = dirname(filePath);
106
- mkdirSync(dir, { recursive: true });
107
- const tmpPath = join(dir, `.tmp-${Date.now()}-${process.pid}-${randomUUID()}`);
108
- writeFileSync(tmpPath, content, "utf8");
109
- renameSync(tmpPath, filePath);
110
- }
111
-
112
- /**
113
- * Remove empty parent directories recursively up to stopDir.
114
- */
115
- function cleanEmptyParents(dir, stopDir) {
116
- let current = resolve(dir);
117
- const stop = resolve(stopDir);
118
- while (current && current !== stop && current.startsWith(stop)) {
119
- try {
120
- const remaining = readdirSync(current);
121
- if (remaining.length === 0) {
122
- rmdirSync(current);
123
- current = dirname(current);
124
- } else {
125
- break;
126
- }
127
- } catch {
128
- break;
129
- }
130
- }
131
- }
132
-
133
- /**
134
- * Scan a directory recursively for all .geml files.
135
- * @param {string} dir Root directory to scan.
136
- * @param {string} [baseDir] Base directory for computing relative paths.
137
- * @returns {Map<string, string>} Map of relative path (POSIX style) -> file content (normalized LF).
138
- */
139
- export function readGemlFilesFromDisk(dir, baseDir = dir) {
140
- const files = new Map();
141
- if (!existsSync(dir)) return files;
142
-
143
- const entries = readdirSync(dir, { withFileTypes: true });
144
- for (const entry of entries) {
145
- const fullPath = join(dir, entry.name);
146
- if (entry.isDirectory()) {
147
- // Ignore .git, node_modules, and hidden directories
148
- if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
149
- const subFiles = readGemlFilesFromDisk(fullPath, baseDir);
150
- for (const [rel, content] of subFiles) {
151
- files.set(rel, content);
152
- }
153
- } else if (entry.isFile() && entry.name.endsWith(".geml")) {
154
- const rel = relative(baseDir, fullPath).split(sep).join("/");
155
- files.set(rel, normalizeEol(readFileSync(fullPath, "utf8")));
156
- }
157
- }
158
- return files;
159
- }
160
-
161
- /**
162
- * Incrementally sync a Map of GEML files to disk.
163
- * Only writes files whose content has changed or do not yet exist.
164
- * Detects files on disk that are absent from the export (e.g. deleted pages or journals),
165
- * and reports them without destructive deletion by default.
166
- *
167
- * Safety note: @logseq/cli 0.4.3 does not include journals in export-edn.
168
- * Manifest-based orphan tracking ensures user-authored files outside previous syncs are never deleted.
169
- * Destructive deletion requires explicit `opts.deleteOrphans === true`.
170
- *
171
- * @param {Map<string, string>} gemlFiles Map of relative path (POSIX) -> gemlText.
172
- * @param {string} targetDir Local destination directory.
173
- * @param {object} [opts]
174
- * @param {boolean} [opts.deleteOrphans=false] Whether to delete previous-sync .geml files no longer in graph.
175
- * @param {string[]} [opts.preserve] Files NOT to overwrite even when the graph
176
- * differs the conflicted files of a two-way cycle. Their manifest entry
177
- * keeps its previous hash, so they stay flagged until a person resolves them.
178
- * @returns {{ written: string[], orphaned: string[], unchanged: string[], deleted: string[], preserved: string[] }}
179
- */
180
- export function writeGemlFilesToDisk(gemlFiles, targetDir, opts = {}) {
181
- const deleteOrphans = opts.deleteOrphans ?? false;
182
- const preserve = new Set(opts.preserve ?? []);
183
- const written = [];
184
- const unchanged = [];
185
- const orphaned = [];
186
- const deleted = [];
187
- const preserved = [];
188
-
189
- mkdirSync(targetDir, { recursive: true });
190
- const existingFiles = readGemlFilesFromDisk(targetDir);
191
-
192
- // Load previous sync manifest to know which files belong to sync vs user-authored files
193
- const manifestPath = join(targetDir, MANIFEST_FILE);
194
- const previous = readManifest(targetDir);
195
- const lastManifest = new Set(previous.files.keys());
196
-
197
- // Write new or updated files atomically with CRLF normalization
198
- for (const [rel, newContent] of gemlFiles) {
199
- const fullPath = join(targetDir, rel);
200
- const normNew = normalizeEol(newContent);
201
- const existingContent = existingFiles.get(rel);
202
-
203
- if (preserve.has(rel)) {
204
- if (existingContent !== normNew) preserved.push(rel);
205
- else unchanged.push(rel);
206
- } else if (existingContent === undefined || existingContent !== normNew) {
207
- atomicWriteFileSync(fullPath, normNew);
208
- written.push(rel);
209
- } else {
210
- unchanged.push(rel);
211
- }
212
- }
213
-
214
- // Detect files present on disk but absent from current graph export
215
- for (const [rel] of existingFiles) {
216
- if (!gemlFiles.has(rel)) {
217
- orphaned.push(rel);
218
- // Safe deletion: only delete if explicit AND file was generated by previous sync
219
- // User-authored files in targetDir not in manifest are NEVER deleted.
220
- if (deleteOrphans && lastManifest.has(rel)) {
221
- const fullPath = join(targetDir, rel);
222
- if (existsSync(fullPath)) {
223
- unlinkSync(fullPath);
224
- cleanEmptyParents(dirname(fullPath), targetDir);
225
- deleted.push(rel);
226
- }
227
- }
228
- }
229
- }
230
-
231
- // Save updated manifest of managed sync files: all current gemlFiles, plus
232
- // any existing files on disk that were in lastManifest and not deleted.
233
- // v2 records each file's content hash AS OF THIS SYNC the baseline
234
- // detectExternalEdits() compares against, so the watcher's own writes never
235
- // read as someone else's edits.
236
- const currentManifest = new Set(gemlFiles.keys());
237
- for (const rel of lastManifest) {
238
- if (existingFiles.has(rel) && !deleted.includes(rel)) {
239
- currentManifest.add(rel);
240
- }
241
- }
242
- const manifestFiles = {};
243
- for (const rel of [...currentManifest].sort()) {
244
- if (preserved.includes(rel)) {
245
- // A conflicted file keeps its OLD baseline: recording what sits on disk
246
- // now would make the person's unmerged edit read as "already synced" on
247
- // the next cycle, and the conflict would be silently forgotten.
248
- manifestFiles[rel] = previous.files.get(rel) ?? null;
249
- continue;
250
- }
251
- const content = gemlFiles.has(rel) ? normalizeEol(gemlFiles.get(rel)) : existingFiles.get(rel);
252
- manifestFiles[rel] = content === undefined ? null : sha256(content);
253
- }
254
- atomicWriteFileSync(manifestPath, JSON.stringify({ version: 2, files: manifestFiles }, null, 1) + "\n");
255
-
256
- return { written, orphaned, unchanged, deleted, preserved };
257
- }
258
-
259
- /**
260
- * Execute Git commands to commit changes scoped strictly to the synced files.
261
- * Protects parent repository from having unrelated files swept into the commit.
262
- *
263
- * @param {string} targetDir Directory where the sync target lives.
264
- * @param {string} commitMessage Commit message.
265
- * @param {string[]} pathsToCommit Relative paths within targetDir that were written or deleted.
266
- * @param {function} [gitRunner] Optional custom git runner `(args) => Promise<{ stdout, stderr, exitCode }>`.
267
- * @returns {Promise<{ committed: boolean, changes: boolean, output: string }>}
268
- */
269
- export async function gitAutoCommit(targetDir, commitMessage = "logseq-geml sync", pathsToCommit = [], gitRunner = null) {
270
- const defaultRunner = async (args) => {
271
- try {
272
- const stdout = execFileSync("git", args, {
273
- cwd: targetDir,
274
- encoding: "utf8",
275
- stdio: ["ignore", "pipe", "pipe"],
276
- });
277
- return { stdout, stderr: "", exitCode: 0 };
278
- } catch (err) {
279
- return {
280
- stdout: err.stdout ? String(err.stdout) : "",
281
- stderr: err.stderr ? String(err.stderr) : err.message,
282
- exitCode: err.status || 1,
283
- };
284
- }
285
- };
286
-
287
- const run = gitRunner || defaultRunner;
288
-
289
- // Check if targetDir is inside a git repository
290
- const revRes = await run(["rev-parse", "--show-toplevel"]);
291
- if (revRes.exitCode !== 0) {
292
- return {
293
- committed: false,
294
- changes: false,
295
- output: `Not a git repository: ${revRes.stderr.trim()}`,
296
- };
297
- }
298
-
299
- if (!pathsToCommit || pathsToCommit.length === 0) {
300
- return {
301
- committed: false,
302
- changes: false,
303
- output: "No synced paths to commit.",
304
- };
305
- }
306
-
307
- // Always include the manifest file in staged paths
308
- const allPaths = [...new Set([...pathsToCommit, MANIFEST_FILE])];
309
-
310
- // Stage ONLY the specified paths (never a bare git add -A)
311
- // Split into existing files vs deleted files
312
- const toAdd = allPaths.filter((p) => existsSync(join(targetDir, p)));
313
- const toRemove = allPaths.filter((p) => !existsSync(join(targetDir, p)));
314
-
315
- if (toAdd.length > 0) {
316
- const addRes = await run(["add", "--", ...toAdd]);
317
- if (addRes.exitCode !== 0) {
318
- return { committed: false, changes: true, output: `git add failed: ${addRes.stderr}` };
319
- }
320
- }
321
-
322
- if (toRemove.length > 0) {
323
- const rmRes = await run(["add", "-u", "--", ...toRemove]);
324
- if (rmRes.exitCode !== 0) {
325
- return { committed: false, changes: true, output: `git update index failed: ${rmRes.stderr}` };
326
- }
327
- }
328
-
329
- // Verify whether our target paths have staged changes
330
- const statusRes = await run(["status", "--porcelain", "--", ...allPaths]);
331
- if (statusRes.exitCode !== 0 || statusRes.stdout.trim().length === 0) {
332
- return {
333
- committed: false,
334
- changes: false,
335
- output: "No changes in target paths to commit.",
336
- };
337
- }
338
-
339
- // Commit with pathspec: commits ONLY changes matching our synced paths,
340
- // leaving any other staged or unstaged changes in parent repository untouched!
341
- const commitRes = await run(["commit", "-m", commitMessage, "--", ...allPaths]);
342
- if (commitRes.exitCode !== 0) {
343
- return {
344
- committed: false,
345
- changes: true,
346
- output: `git commit failed: ${commitRes.stderr}`,
347
- };
348
- }
349
-
350
- return {
351
- committed: true,
352
- changes: true,
353
- output: commitRes.stdout.trim(),
354
- };
355
- }
356
-
357
- /**
358
- * Full Sync Pipeline from EDN string to disk.
359
- *
360
- * @param {string} ednText EDN string (from logseq export-edn).
361
- * @param {string} targetDir Destination folder.
362
- * @param {object} [opts]
363
- * @param {boolean} [opts.autoCommit=false]
364
- * @param {string} [opts.commitMessage]
365
- * @param {boolean} [opts.allowEmptyGraph=false] Refuse 0-page export over non-empty targetDir unless true.
366
- * @param {function} [opts.gitRunner]
367
- * @returns {Promise<{ written: string[], deleted: string[], orphaned: string[], unchanged: string[], gitResult?: any }>}
368
- */
369
- export async function syncEdnToDisk(ednText, targetDir, opts = {}) {
370
- // Guard 1: Refuse empty or truncated EDN input
371
- if (!ednText || typeof ednText !== "string" || ednText.trim().length === 0) {
372
- throw new Error("EDN input is empty or truncated; refusing to sync to prevent data loss.");
373
- }
374
-
375
- const gemlFiles = ednToGemlFiles(ednText);
376
-
377
- // Guard 2: Refuse 0-page export if targetDir already has existing pages
378
- const pageCount = [...gemlFiles.keys()].filter((k) => k.startsWith("pages/") || k.startsWith("journals/")).length;
379
- const existingFiles = readGemlFilesFromDisk(targetDir);
380
- const existingPageCount = [...existingFiles.keys()].filter((k) => k.startsWith("pages/") || k.startsWith("journals/")).length;
381
-
382
- if (pageCount === 0 && existingPageCount > 0 && !opts.allowEmptyGraph) {
383
- throw new Error(
384
- `Refusing to sync empty graph (0 pages) over directory with existing pages (${existingPageCount} pages). Pass allowEmptyGraph: true to force.`
385
- );
386
- }
387
-
388
- const diffResult = writeGemlFilesToDisk(gemlFiles, targetDir, opts);
389
-
390
- // A parallel Markdown tree, for people and tools that read Markdown and
391
- // nothing else. Deliberately lossy and deliberately separate: the GEML tree
392
- // stays the one that round-trips. The converter is injected, so this module
393
- // keeps its single dependency.
394
- const markdownWritten = [];
395
- if (opts.markdownDir && typeof opts.gemlToMd === "function") {
396
- for (const [rel, content] of gemlFiles) {
397
- const mdRel = rel.replace(/\.geml$/, ".md");
398
- const full = join(opts.markdownDir, mdRel);
399
- let md;
400
- try {
401
- md = normalizeEol(opts.gemlToMd(content));
402
- } catch {
403
- continue; // one unconvertible document must not fail the sync
404
- }
405
- mkdirSync(dirname(full), { recursive: true });
406
- if (!existsSync(full) || readFileSync(full, "utf8") !== md) {
407
- atomicWriteFileSync(full, md);
408
- markdownWritten.push(mdRel);
409
- }
410
- }
411
- }
412
-
413
- let gitResult = null;
414
- const pathsModified = [...diffResult.written, ...diffResult.deleted];
415
- for (const rel of markdownWritten) {
416
- const abs = join(opts.markdownDir, rel);
417
- const insideVault = relative(targetDir, abs);
418
- if (insideVault && !insideVault.startsWith("..") && !isAbsolute(insideVault)) {
419
- pathsModified.push(insideVault);
420
- }
421
- }
422
-
423
- if (opts.autoCommit && pathsModified.length > 0) {
424
- const msg = opts.commitMessage || `logseq-geml: synced ${diffResult.written.length} modified, ${diffResult.deleted.length} deleted`;
425
- gitResult = await gitAutoCommit(targetDir, msg, pathsModified, opts.gitRunner);
426
- }
427
-
428
- return {
429
- ...diffResult,
430
- markdownWritten,
431
- gitResult,
432
- };
433
- }
434
-
435
- /**
436
- * Full Sync Pipeline from disk back to EDN string.
437
- *
438
- * @param {string} targetDir Local folder containing .geml files.
439
- * @param {object} lib Parser library containing { parse, addressedUnits, sliceUnit }.
440
- * @param {{ exclude?: string[] }} [opts] Files to leave OUT of the import —
441
- * the conflicted files of a two-way cycle: absent from the EDN means the
442
- * graph's version stays untouched (import merges by uuid, it never deletes).
443
- * @returns {string} EDN string ready for logseq import-edn.
444
- */
445
- export function syncDiskToEdn(targetDir, lib, opts = {}) {
446
- const files = readGemlFilesFromDisk(targetDir);
447
- for (const rel of opts.exclude ?? []) files.delete(rel);
448
- return gemlFilesToEdn(files, lib);
449
- }
1
+ // Core Sync Engine for Logseq GEML
2
+ // Keeps a local folder of .geml files in sync with a Logseq DB graph,
3
+ // ensuring only changed files are written so Git diffs stay clean.
4
+
5
+ import {
6
+ readdirSync,
7
+ readFileSync,
8
+ writeFileSync,
9
+ unlinkSync,
10
+ mkdirSync,
11
+ rmdirSync,
12
+ renameSync,
13
+ existsSync,
14
+ } from "node:fs";
15
+ import { join, dirname, relative, resolve, sep, isAbsolute } from "node:path";
16
+ import { execFileSync } from "node:child_process";
17
+ import { randomUUID, createHash } from "node:crypto";
18
+ import { ednToGemlFiles, gemlFilesToEdn } from "./mapping.mjs";
19
+ import { gemlToOgMarkdown } from "./og-markdown.mjs";
20
+
21
+ const MANIFEST_FILE = ".geml-manifest.json";
22
+
23
+ const sha256 = (s) => createHash("sha256").update(s).digest("hex");
24
+
25
+ /**
26
+ * Read the sync manifest in either of its two shapes.
27
+ * v1 was a sorted array of paths enough to know which files the sync owns.
28
+ * v2 ({ version: 2, files: { rel: sha256 } }) also records the content the
29
+ * sync last wrote or saw, which is what lets two-way sync tell an external
30
+ * edit from its own echo: a file whose hash matches the manifest is the
31
+ * watcher's own last write, not something a person or agent changed.
32
+ * @returns {{ known: boolean, hashed: boolean, files: Map<string, string|null> }}
33
+ */
34
+ function readManifest(targetDir) {
35
+ const manifestPath = join(targetDir, MANIFEST_FILE);
36
+ if (!existsSync(manifestPath)) return { known: false, hashed: false, files: new Map() };
37
+ try {
38
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
39
+ if (Array.isArray(parsed)) {
40
+ return { known: true, hashed: false, files: new Map(parsed.map((p) => [p, null])) };
41
+ }
42
+ if (parsed && parsed.version === 2 && parsed.files && typeof parsed.files === "object") {
43
+ return { known: true, hashed: true, files: new Map(Object.entries(parsed.files)) };
44
+ }
45
+ } catch {}
46
+ return { known: false, hashed: false, files: new Map() };
47
+ }
48
+
49
+ /**
50
+ * What changed in the vault since the sync last touched it — the read side of
51
+ * the two-way bridge. Baselines come from the v2 manifest hashes; a v1
52
+ * manifest (or none) knows which files exist but not what they held, so it
53
+ * reports nothing rather than guessing: `baselineKnown: false` means "sync
54
+ * once first".
55
+ *
56
+ * With `graphFiles` (the current export, as ednToGemlFiles returns it) the
57
+ * vault-modified files are split further: one the GRAPH also moved since the
58
+ * last sync is a `conflict` — importing it would clobber the graph's edit,
59
+ * exporting over it would clobber the person's, so two-way sync does neither
60
+ * and a person merges.
61
+ * @param {string} targetDir
62
+ * @param {{ graphFiles?: Map<string, string> }} [opts]
63
+ * @returns {{ baselineKnown: boolean, modified: string[], added: string[], missing: string[], conflicts: string[] }}
64
+ */
65
+ export function detectExternalEdits(targetDir, opts = {}) {
66
+ const manifest = readManifest(targetDir);
67
+ const onDisk = readGemlFilesFromDisk(targetDir);
68
+ const modified = [];
69
+ const added = [];
70
+ const missing = [];
71
+ const conflicts = [];
72
+ if (!manifest.hashed) return { baselineKnown: false, modified, added, missing, conflicts };
73
+ for (const [rel, hash] of manifest.files) {
74
+ const content = onDisk.get(rel);
75
+ if (content === undefined) missing.push(rel);
76
+ else if (hash !== null && sha256(content) !== hash) {
77
+ const graphContent = opts.graphFiles?.get(rel);
78
+ const graphMoved =
79
+ graphContent !== undefined && sha256(normalizeEol(graphContent)) !== hash;
80
+ (graphMoved ? conflicts : modified).push(rel);
81
+ }
82
+ }
83
+ for (const rel of onDisk.keys()) {
84
+ if (!manifest.files.has(rel)) added.push(rel);
85
+ }
86
+ return {
87
+ baselineKnown: true,
88
+ modified: modified.sort(),
89
+ added: added.sort(),
90
+ missing: missing.sort(),
91
+ conflicts: conflicts.sort(),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Normalize line endings to LF, handling CRLF (\r\n) and lone CR (\r).
97
+ */
98
+ export function normalizeEol(str) {
99
+ return typeof str === "string" ? str.replace(/\r\n?/g, "\n") : str;
100
+ }
101
+
102
+ /**
103
+ * Atomically write a file via a temporary file in the same directory.
104
+ */
105
+ export function atomicWriteFileSync(filePath, content) {
106
+ const dir = dirname(filePath);
107
+ mkdirSync(dir, { recursive: true });
108
+ const tmpPath = join(dir, `.tmp-${Date.now()}-${process.pid}-${randomUUID()}`);
109
+ writeFileSync(tmpPath, content, "utf8");
110
+ renameSync(tmpPath, filePath);
111
+ }
112
+
113
+ /**
114
+ * Remove empty parent directories recursively up to stopDir.
115
+ */
116
+ function cleanEmptyParents(dir, stopDir) {
117
+ let current = resolve(dir);
118
+ const stop = resolve(stopDir);
119
+ while (current && current !== stop && current.startsWith(stop)) {
120
+ try {
121
+ const remaining = readdirSync(current);
122
+ if (remaining.length === 0) {
123
+ rmdirSync(current);
124
+ current = dirname(current);
125
+ } else {
126
+ break;
127
+ }
128
+ } catch {
129
+ break;
130
+ }
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Scan a directory recursively for all .geml files.
136
+ * @param {string} dir Root directory to scan.
137
+ * @param {string} [baseDir] Base directory for computing relative paths.
138
+ * @returns {Map<string, string>} Map of relative path (POSIX style) -> file content (normalized LF).
139
+ */
140
+ export function readGemlFilesFromDisk(dir, baseDir = dir) {
141
+ const files = new Map();
142
+ if (!existsSync(dir)) return files;
143
+
144
+ const entries = readdirSync(dir, { withFileTypes: true });
145
+ for (const entry of entries) {
146
+ const fullPath = join(dir, entry.name);
147
+ if (entry.isDirectory()) {
148
+ // Ignore .git, node_modules, and hidden directories
149
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
150
+ const subFiles = readGemlFilesFromDisk(fullPath, baseDir);
151
+ for (const [rel, content] of subFiles) {
152
+ files.set(rel, content);
153
+ }
154
+ } else if (entry.isFile() && entry.name.endsWith(".geml")) {
155
+ const rel = relative(baseDir, fullPath).split(sep).join("/");
156
+ files.set(rel, normalizeEol(readFileSync(fullPath, "utf8")));
157
+ }
158
+ }
159
+ return files;
160
+ }
161
+
162
+ /**
163
+ * Incrementally sync a Map of GEML files to disk.
164
+ * Only writes files whose content has changed or do not yet exist.
165
+ * Detects files on disk that are absent from the export (e.g. deleted pages or journals),
166
+ * and reports them without destructive deletion by default.
167
+ *
168
+ * Safety note: @logseq/cli 0.4.3 does not include journals in export-edn.
169
+ * Manifest-based orphan tracking ensures user-authored files outside previous syncs are never deleted.
170
+ * Destructive deletion requires explicit `opts.deleteOrphans === true`.
171
+ *
172
+ * @param {Map<string, string>} gemlFiles Map of relative path (POSIX) -> gemlText.
173
+ * @param {string} targetDir Local destination directory.
174
+ * @param {object} [opts]
175
+ * @param {boolean} [opts.deleteOrphans=false] Whether to delete previous-sync .geml files no longer in graph.
176
+ * @param {string[]} [opts.preserve] Files NOT to overwrite even when the graph
177
+ * differs the conflicted files of a two-way cycle. Their manifest entry
178
+ * keeps its previous hash, so they stay flagged until a person resolves them.
179
+ * @returns {{ written: string[], orphaned: string[], unchanged: string[], deleted: string[], preserved: string[] }}
180
+ */
181
+ export function writeGemlFilesToDisk(gemlFiles, targetDir, opts = {}) {
182
+ const deleteOrphans = opts.deleteOrphans ?? false;
183
+ const preserve = new Set(opts.preserve ?? []);
184
+ const written = [];
185
+ const unchanged = [];
186
+ const orphaned = [];
187
+ const deleted = [];
188
+ const preserved = [];
189
+
190
+ mkdirSync(targetDir, { recursive: true });
191
+ const existingFiles = readGemlFilesFromDisk(targetDir);
192
+
193
+ // Load previous sync manifest to know which files belong to sync vs user-authored files
194
+ const manifestPath = join(targetDir, MANIFEST_FILE);
195
+ const previous = readManifest(targetDir);
196
+ const lastManifest = new Set(previous.files.keys());
197
+
198
+ // Write new or updated files atomically with CRLF normalization
199
+ for (const [rel, newContent] of gemlFiles) {
200
+ const fullPath = join(targetDir, rel);
201
+ const normNew = normalizeEol(newContent);
202
+ const existingContent = existingFiles.get(rel);
203
+
204
+ if (preserve.has(rel)) {
205
+ if (existingContent !== normNew) preserved.push(rel);
206
+ else unchanged.push(rel);
207
+ } else if (existingContent === undefined || existingContent !== normNew) {
208
+ atomicWriteFileSync(fullPath, normNew);
209
+ written.push(rel);
210
+ } else {
211
+ unchanged.push(rel);
212
+ }
213
+ }
214
+
215
+ // Detect files present on disk but absent from current graph export
216
+ for (const [rel] of existingFiles) {
217
+ if (!gemlFiles.has(rel)) {
218
+ orphaned.push(rel);
219
+ // Safe deletion: only delete if explicit AND file was generated by previous sync
220
+ // User-authored files in targetDir not in manifest are NEVER deleted.
221
+ if (deleteOrphans && lastManifest.has(rel)) {
222
+ const fullPath = join(targetDir, rel);
223
+ if (existsSync(fullPath)) {
224
+ unlinkSync(fullPath);
225
+ cleanEmptyParents(dirname(fullPath), targetDir);
226
+ deleted.push(rel);
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ // Save updated manifest of managed sync files: all current gemlFiles, plus
233
+ // any existing files on disk that were in lastManifest and not deleted.
234
+ // v2 records each file's content hash AS OF THIS SYNC — the baseline
235
+ // detectExternalEdits() compares against, so the watcher's own writes never
236
+ // read as someone else's edits.
237
+ const currentManifest = new Set(gemlFiles.keys());
238
+ for (const rel of lastManifest) {
239
+ if (existingFiles.has(rel) && !deleted.includes(rel)) {
240
+ currentManifest.add(rel);
241
+ }
242
+ }
243
+ const manifestFiles = {};
244
+ for (const rel of [...currentManifest].sort()) {
245
+ if (preserved.includes(rel)) {
246
+ // A conflicted file keeps its OLD baseline: recording what sits on disk
247
+ // now would make the person's unmerged edit read as "already synced" on
248
+ // the next cycle, and the conflict would be silently forgotten.
249
+ manifestFiles[rel] = previous.files.get(rel) ?? null;
250
+ continue;
251
+ }
252
+ const content = gemlFiles.has(rel) ? normalizeEol(gemlFiles.get(rel)) : existingFiles.get(rel);
253
+ manifestFiles[rel] = content === undefined ? null : sha256(content);
254
+ }
255
+ atomicWriteFileSync(manifestPath, JSON.stringify({ version: 2, files: manifestFiles }, null, 1) + "\n");
256
+
257
+ return { written, orphaned, unchanged, deleted, preserved };
258
+ }
259
+
260
+ /**
261
+ * Execute Git commands to commit changes scoped strictly to the synced files.
262
+ * Protects parent repository from having unrelated files swept into the commit.
263
+ *
264
+ * @param {string} targetDir Directory where the sync target lives.
265
+ * @param {string} commitMessage Commit message.
266
+ * @param {string[]} pathsToCommit Relative paths within targetDir that were written or deleted.
267
+ * @param {function} [gitRunner] Optional custom git runner `(args) => Promise<{ stdout, stderr, exitCode }>`.
268
+ * @returns {Promise<{ committed: boolean, changes: boolean, output: string }>}
269
+ */
270
+ export async function gitAutoCommit(targetDir, commitMessage = "logseq-geml sync", pathsToCommit = [], gitRunner = null) {
271
+ const defaultRunner = async (args) => {
272
+ try {
273
+ const stdout = execFileSync("git", args, {
274
+ cwd: targetDir,
275
+ encoding: "utf8",
276
+ stdio: ["ignore", "pipe", "pipe"],
277
+ });
278
+ return { stdout, stderr: "", exitCode: 0 };
279
+ } catch (err) {
280
+ return {
281
+ stdout: err.stdout ? String(err.stdout) : "",
282
+ stderr: err.stderr ? String(err.stderr) : err.message,
283
+ exitCode: err.status || 1,
284
+ };
285
+ }
286
+ };
287
+
288
+ const run = gitRunner || defaultRunner;
289
+
290
+ // Check if targetDir is inside a git repository
291
+ const revRes = await run(["rev-parse", "--show-toplevel"]);
292
+ if (revRes.exitCode !== 0) {
293
+ return {
294
+ committed: false,
295
+ changes: false,
296
+ output: `Not a git repository: ${revRes.stderr.trim()}`,
297
+ };
298
+ }
299
+
300
+ if (!pathsToCommit || pathsToCommit.length === 0) {
301
+ return {
302
+ committed: false,
303
+ changes: false,
304
+ output: "No synced paths to commit.",
305
+ };
306
+ }
307
+
308
+ // Always include the manifest file in staged paths
309
+ const allPaths = [...new Set([...pathsToCommit, MANIFEST_FILE])];
310
+
311
+ // Stage ONLY the specified paths (never a bare git add -A)
312
+ // Split into existing files vs deleted files
313
+ const toAdd = allPaths.filter((p) => existsSync(join(targetDir, p)));
314
+ const toRemove = allPaths.filter((p) => !existsSync(join(targetDir, p)));
315
+
316
+ if (toAdd.length > 0) {
317
+ const addRes = await run(["add", "--", ...toAdd]);
318
+ if (addRes.exitCode !== 0) {
319
+ return { committed: false, changes: true, output: `git add failed: ${addRes.stderr}` };
320
+ }
321
+ }
322
+
323
+ if (toRemove.length > 0) {
324
+ const rmRes = await run(["add", "-u", "--", ...toRemove]);
325
+ if (rmRes.exitCode !== 0) {
326
+ return { committed: false, changes: true, output: `git update index failed: ${rmRes.stderr}` };
327
+ }
328
+ }
329
+
330
+ // Verify whether our target paths have staged changes
331
+ const statusRes = await run(["status", "--porcelain", "--", ...allPaths]);
332
+ if (statusRes.exitCode !== 0 || statusRes.stdout.trim().length === 0) {
333
+ return {
334
+ committed: false,
335
+ changes: false,
336
+ output: "No changes in target paths to commit.",
337
+ };
338
+ }
339
+
340
+ // Commit with pathspec: commits ONLY changes matching our synced paths,
341
+ // leaving any other staged or unstaged changes in parent repository untouched!
342
+ const commitRes = await run(["commit", "-m", commitMessage, "--", ...allPaths]);
343
+ if (commitRes.exitCode !== 0) {
344
+ return {
345
+ committed: false,
346
+ changes: true,
347
+ output: `git commit failed: ${commitRes.stderr}`,
348
+ };
349
+ }
350
+
351
+ return {
352
+ committed: true,
353
+ changes: true,
354
+ output: commitRes.stdout.trim(),
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Full Sync Pipeline from EDN string to disk.
360
+ *
361
+ * @param {string} ednText EDN string (from logseq export-edn).
362
+ * @param {string} targetDir Destination folder.
363
+ * @param {object} [opts]
364
+ * @param {boolean} [opts.autoCommit=false]
365
+ * @param {string} [opts.commitMessage]
366
+ * @param {boolean} [opts.allowEmptyGraph=false] Refuse 0-page export over non-empty targetDir unless true.
367
+ * @param {function} [opts.gitRunner]
368
+ * @returns {Promise<{ written: string[], deleted: string[], orphaned: string[], unchanged: string[], gitResult?: any }>}
369
+ */
370
+ export async function syncEdnToDisk(ednText, targetDir, opts = {}) {
371
+ // Guard 1: Refuse empty or truncated EDN input
372
+ if (!ednText || typeof ednText !== "string" || ednText.trim().length === 0) {
373
+ throw new Error("EDN input is empty or truncated; refusing to sync to prevent data loss.");
374
+ }
375
+
376
+ const gemlFiles = ednToGemlFiles(ednText);
377
+
378
+ // Guard 2: Refuse 0-page export if targetDir already has existing pages
379
+ const pageCount = [...gemlFiles.keys()].filter((k) => k.startsWith("pages/") || k.startsWith("journals/")).length;
380
+ const existingFiles = readGemlFilesFromDisk(targetDir);
381
+ const existingPageCount = [...existingFiles.keys()].filter((k) => k.startsWith("pages/") || k.startsWith("journals/")).length;
382
+
383
+ if (pageCount === 0 && existingPageCount > 0 && !opts.allowEmptyGraph) {
384
+ throw new Error(
385
+ `Refusing to sync empty graph (0 pages) over directory with existing pages (${existingPageCount} pages). Pass allowEmptyGraph: true to force.`
386
+ );
387
+ }
388
+
389
+ const diffResult = writeGemlFilesToDisk(gemlFiles, targetDir, opts);
390
+
391
+ // A parallel Markdown tree in LOGSEQ'S OWN dialect bullets, `id::`,
392
+ // `((uuid))` refs — so the directory opens as a graph in the file version of
393
+ // the app. Generic GEML-to-Markdown is `geml <file> --to md`, the parser's
394
+ // job; the only reason this integration writes Markdown at all is Logseq,
395
+ // and writing anything else here would throw away the uuids and the outline
396
+ // depth that make an OG graph an OG graph.
397
+ //
398
+ // Deliberately lossy and one-way: the GEML tree stays the one that
399
+ // round-trips, and `restore` never reads this. The parser library is
400
+ // injected, so core keeps its single dependency.
401
+ const markdownWritten = [];
402
+ if (opts.markdownDir && opts.lib) {
403
+ for (const [rel, content] of gemlFiles) {
404
+ // Only pages and journals are a graph; the index and the ontology carry
405
+ // machine bookkeeping OG has no page for.
406
+ if (!rel.startsWith("pages/") && !rel.startsWith("journals/")) continue;
407
+ const mdRel = rel.replace(/\.geml$/, ".md");
408
+ const full = join(opts.markdownDir, mdRel);
409
+ let md;
410
+ try {
411
+ md = normalizeEol(gemlToOgMarkdown(content, opts.lib));
412
+ } catch {
413
+ continue; // one unconvertible document must not fail the sync
414
+ }
415
+ if (md === "") continue; // nothing OG can hold — write no file
416
+ mkdirSync(dirname(full), { recursive: true });
417
+ if (!existsSync(full) || readFileSync(full, "utf8") !== md) {
418
+ atomicWriteFileSync(full, md);
419
+ markdownWritten.push(mdRel);
420
+ }
421
+ }
422
+ }
423
+
424
+ let gitResult = null;
425
+ const pathsModified = [...diffResult.written, ...diffResult.deleted];
426
+ for (const rel of markdownWritten) {
427
+ const abs = join(opts.markdownDir, rel);
428
+ const insideVault = relative(targetDir, abs);
429
+ if (insideVault && !insideVault.startsWith("..") && !isAbsolute(insideVault)) {
430
+ pathsModified.push(insideVault);
431
+ }
432
+ }
433
+
434
+ if (opts.autoCommit && pathsModified.length > 0) {
435
+ const msg = opts.commitMessage || `logseq-geml: synced ${diffResult.written.length} modified, ${diffResult.deleted.length} deleted`;
436
+ gitResult = await gitAutoCommit(targetDir, msg, pathsModified, opts.gitRunner);
437
+ }
438
+
439
+ return {
440
+ ...diffResult,
441
+ markdownWritten,
442
+ gitResult,
443
+ };
444
+ }
445
+
446
+ /**
447
+ * Full Sync Pipeline from disk back to EDN string.
448
+ *
449
+ * @param {string} targetDir Local folder containing .geml files.
450
+ * @param {object} lib Parser library containing { parse, addressedUnits, sliceUnit }.
451
+ * @param {{ exclude?: string[] }} [opts] Files to leave OUT of the import —
452
+ * the conflicted files of a two-way cycle: absent from the EDN means the
453
+ * graph's version stays untouched (import merges by uuid, it never deletes).
454
+ * @returns {string} EDN string ready for logseq import-edn.
455
+ */
456
+ export function syncDiskToEdn(targetDir, lib, opts = {}) {
457
+ const files = readGemlFilesFromDisk(targetDir);
458
+ for (const rel of opts.exclude ?? []) files.delete(rel);
459
+ return gemlFilesToEdn(files, lib);
460
+ }