@geml/logseq-sync 2.0.9 → 2.2.0
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/LICENSE +27 -28
- package/README.md +396 -377
- package/core/src/bridge.mjs +8 -8
- package/core/src/discovery.mjs +204 -204
- package/core/src/sync-engine.mjs +623 -560
- package/docs/how-it-works.svg +42 -42
- package/package.json +58 -58
- package/watcher/bin/create-graph.mjs +51 -51
- package/watcher/bin/create_graph_headless.cljs +22 -22
- package/watcher/bin/live-roundtrip.mjs +131 -131
- package/watcher/bin/logseq-sync.mjs +994 -940
|
@@ -1,940 +1,994 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// logseq-sync — a Logseq DB graph ➔ a Git-friendly folder of readable GEML files.
|
|
3
|
-
// The full usage is the USAGE constant below, printed by `logseq-sync --help`.
|
|
4
|
-
|
|
5
|
-
import { execFileSync } from "node:child_process";
|
|
6
|
-
import {
|
|
7
|
-
readFileSync, unlinkSync, existsSync, statSync, mkdirSync, readdirSync, watch,
|
|
8
|
-
} from "node:fs";
|
|
9
|
-
import { join, resolve, dirname, basename, sep } from "node:path";
|
|
10
|
-
import { tmpdir, homedir } from "node:os";
|
|
11
|
-
import { randomUUID, createHash } from "node:crypto";
|
|
12
|
-
import { syncEdnToDisk, syncDiskToEdn, atomicWriteFileSync, detectExternalEdits } from "../../core/src/sync-engine.mjs";
|
|
13
|
-
import { ednToGemlFiles } from "../../core/src/mapping.mjs";
|
|
14
|
-
import { STATUS_FILE } from "../../core/src/bridge.mjs";
|
|
15
|
-
import { parse as parseGeml, addressedUnits, sliceUnit } from "@geml/geml";
|
|
16
|
-
|
|
17
|
-
// The engine takes the parser injected, so core keeps its single dependency.
|
|
18
|
-
const gemlLib = { parse: parseGeml, addressedUnits, sliceUnit };
|
|
19
|
-
import {
|
|
20
|
-
PLUGIN_ID, logseqDotDir, logseqRootDir, signalFilePath, pluginSettings,
|
|
21
|
-
findAppCli, appCliCandidates, detectGraph, detectGraphViaCli, parseManagedShim,
|
|
22
|
-
} from "../../core/src/discovery.mjs";
|
|
23
|
-
|
|
24
|
-
const PLUGIN_TITLE = "Sync Vault with GEML";
|
|
25
|
-
|
|
26
|
-
const USAGE = `logseq-sync — a Logseq DB graph ➔ a Git-friendly folder of readable GEML files.
|
|
27
|
-
|
|
28
|
-
Usage:
|
|
29
|
-
logseq-sync [vault-dir] [flags] vault-dir defaults to the plugin's setting
|
|
30
|
-
logseq-sync <graph> <vault-dir> [flags] explicit form, when you have several graphs
|
|
31
|
-
logseq-sync doctor report what was detected and what is missing
|
|
32
|
-
logseq-sync restore [vault-dir] vault ➔ graph. Rehearses; --yes performs it,
|
|
33
|
-
taking a graph backup first (--no-backup to skip)
|
|
34
|
-
|
|
35
|
-
Whatever can be worked out, is: the CLI that ships inside the desktop app,
|
|
36
|
-
which graph the app currently has open, where the plugin's signal file lives,
|
|
37
|
-
and where you told the plugin to put the vault. Every one of them has a flag
|
|
38
|
-
to override it.
|
|
39
|
-
|
|
40
|
-
Flags:
|
|
41
|
-
--once Sync once and exit (default: keep watching)
|
|
42
|
-
--two-way Also import vault edits back into the graph, checked
|
|
43
|
-
on every cycle. A file changed on BOTH sides is a
|
|
44
|
-
conflict: neither imported nor overwritten, reported
|
|
45
|
-
until you merge it. Deletions are never imported.
|
|
46
|
-
Takes a graph backup before the first import and
|
|
47
|
-
every 10th after. Needs the app CLI.
|
|
48
|
-
--git-commit Commit, creating the vault repository if there is none
|
|
49
|
-
(default: commit only when the vault ALREADY is a repository)
|
|
50
|
-
--no-git-commit Never touch git
|
|
51
|
-
--mirror Delete vault files for pages removed from the graph
|
|
52
|
-
(default: keep them, and report the divergence)
|
|
53
|
-
--overwrite-unmanaged Overwrite files that were already there when the sync
|
|
54
|
-
first ran (default: hold them and name them — a file
|
|
55
|
-
no manifest claims was written by someone else)
|
|
56
|
-
--markdown <dir>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
--
|
|
64
|
-
--
|
|
65
|
-
--
|
|
66
|
-
--no-
|
|
67
|
-
--
|
|
68
|
-
--
|
|
69
|
-
--
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
} else if (arg === "--
|
|
110
|
-
|
|
111
|
-
} else if (arg === "--
|
|
112
|
-
flags.
|
|
113
|
-
} else if (arg === "--
|
|
114
|
-
flags.gitCommit =
|
|
115
|
-
} else if (arg === "--
|
|
116
|
-
flags.
|
|
117
|
-
} else if (arg === "--
|
|
118
|
-
flags.
|
|
119
|
-
} else if (arg === "--
|
|
120
|
-
flags.
|
|
121
|
-
} else if (arg === "--
|
|
122
|
-
flags.
|
|
123
|
-
} else if (arg === "--
|
|
124
|
-
flags.
|
|
125
|
-
} else if (arg === "--
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
flags.
|
|
130
|
-
} else if (arg === "--no-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
flags.
|
|
147
|
-
} else if (arg === "--
|
|
148
|
-
needValue(i, "--
|
|
149
|
-
flags.
|
|
150
|
-
} else if (arg === "--
|
|
151
|
-
needValue(i, "--
|
|
152
|
-
flags.
|
|
153
|
-
} else if (arg === "--
|
|
154
|
-
needValue(i, "--
|
|
155
|
-
flags.
|
|
156
|
-
} else if (arg
|
|
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
|
-
|
|
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
|
-
process.exit(2);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
return
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
function
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
);
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
if (
|
|
371
|
-
|
|
372
|
-
else
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
);
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
//
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
)
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
//
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// logseq-sync — a Logseq DB graph ➔ a Git-friendly folder of readable GEML files.
|
|
3
|
+
// The full usage is the USAGE constant below, printed by `logseq-sync --help`.
|
|
4
|
+
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import {
|
|
7
|
+
readFileSync, unlinkSync, existsSync, statSync, mkdirSync, readdirSync, watch,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { join, resolve, dirname, basename, sep } from "node:path";
|
|
10
|
+
import { tmpdir, homedir } from "node:os";
|
|
11
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
12
|
+
import { syncEdnToDisk, syncDiskToEdn, atomicWriteFileSync, detectExternalEdits } from "../../core/src/sync-engine.mjs";
|
|
13
|
+
import { ednToGemlFiles } from "../../core/src/mapping.mjs";
|
|
14
|
+
import { STATUS_FILE } from "../../core/src/bridge.mjs";
|
|
15
|
+
import { parse as parseGeml, addressedUnits, sliceUnit } from "@geml/geml";
|
|
16
|
+
|
|
17
|
+
// The engine takes the parser injected, so core keeps its single dependency.
|
|
18
|
+
const gemlLib = { parse: parseGeml, addressedUnits, sliceUnit };
|
|
19
|
+
import {
|
|
20
|
+
PLUGIN_ID, logseqDotDir, logseqRootDir, signalFilePath, pluginSettings,
|
|
21
|
+
findAppCli, appCliCandidates, detectGraph, detectGraphViaCli, parseManagedShim,
|
|
22
|
+
} from "../../core/src/discovery.mjs";
|
|
23
|
+
|
|
24
|
+
const PLUGIN_TITLE = "Sync Vault with GEML";
|
|
25
|
+
|
|
26
|
+
const USAGE = `logseq-sync — a Logseq DB graph ➔ a Git-friendly folder of readable GEML files.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
logseq-sync [vault-dir] [flags] vault-dir defaults to the plugin's setting
|
|
30
|
+
logseq-sync <graph> <vault-dir> [flags] explicit form, when you have several graphs
|
|
31
|
+
logseq-sync doctor report what was detected and what is missing
|
|
32
|
+
logseq-sync restore [vault-dir] vault ➔ graph. Rehearses; --yes performs it,
|
|
33
|
+
taking a graph backup first (--no-backup to skip)
|
|
34
|
+
|
|
35
|
+
Whatever can be worked out, is: the CLI that ships inside the desktop app,
|
|
36
|
+
which graph the app currently has open, where the plugin's signal file lives,
|
|
37
|
+
and where you told the plugin to put the vault. Every one of them has a flag
|
|
38
|
+
to override it.
|
|
39
|
+
|
|
40
|
+
Flags:
|
|
41
|
+
--once Sync once and exit (default: keep watching)
|
|
42
|
+
--two-way Also import vault edits back into the graph, checked
|
|
43
|
+
on every cycle. A file changed on BOTH sides is a
|
|
44
|
+
conflict: neither imported nor overwritten, reported
|
|
45
|
+
until you merge it. Deletions are never imported.
|
|
46
|
+
Takes a graph backup before the first import and
|
|
47
|
+
every 10th after. Needs the app CLI.
|
|
48
|
+
--git-commit Commit, creating the vault repository if there is none
|
|
49
|
+
(default: commit only when the vault ALREADY is a repository)
|
|
50
|
+
--no-git-commit Never touch git
|
|
51
|
+
--mirror Delete vault files for pages removed from the graph
|
|
52
|
+
(default: keep them, and report the divergence)
|
|
53
|
+
--overwrite-unmanaged Overwrite files that were already there when the sync
|
|
54
|
+
first ran (default: hold them and name them — a file
|
|
55
|
+
no manifest claims was written by someone else)
|
|
56
|
+
--markdown <dir> Write the OG (file-version) Markdown graph SOMEWHERE
|
|
57
|
+
ELSE. By default it goes to the vault root, which is
|
|
58
|
+
what makes the vault a folder Logseq opens: bullets,
|
|
59
|
+
id:: lines, ((uuid)) refs. Lossy and one-way
|
|
60
|
+
(properties, tags and data blocks have no OG shape);
|
|
61
|
+
the GEML tree under .logseq-sync-vault-with-geml/ stays
|
|
62
|
+
the one that round-trips, and restore never reads this.
|
|
63
|
+
--no-markdown Write no Markdown at all — GEML tree only.
|
|
64
|
+
--graph <name> Graph to export (default: the one the app has open)
|
|
65
|
+
--app-cli <path> The desktop app's CLI (default: found on PATH, or the app bundle)
|
|
66
|
+
--no-app-cli Force the @logseq/cli fallback, which cannot read an open graph
|
|
67
|
+
--signal <file> Plugin bridge file (default: found in the plugin's storage dir)
|
|
68
|
+
--no-signal Ignore the bridge; poll on the interval only
|
|
69
|
+
--interval <seconds> Poll interval for watch mode (positive integer, default: 10)
|
|
70
|
+
--message <text> Custom git commit message
|
|
71
|
+
--api-server-token <token>
|
|
72
|
+
Route the @logseq/cli fallback through the app's HTTP API
|
|
73
|
+
server. Prefer LOGSEQ_API_SERVER_TOKEN — a token in argv is
|
|
74
|
+
readable by every process on the machine via \`ps\`.
|
|
75
|
+
--help, -h This text`;
|
|
76
|
+
|
|
77
|
+
const args = process.argv.slice(2);
|
|
78
|
+
const positional = [];
|
|
79
|
+
const flags = {
|
|
80
|
+
once: false,
|
|
81
|
+
twoWay: false,
|
|
82
|
+
gitCommit: "auto",
|
|
83
|
+
mirror: false,
|
|
84
|
+
overwriteUnmanaged: undefined, // undefined = fall through to the plugin setting
|
|
85
|
+
markdown: null,
|
|
86
|
+
yes: false,
|
|
87
|
+
backup: true,
|
|
88
|
+
interval: 10,
|
|
89
|
+
message: null,
|
|
90
|
+
signal: undefined, // undefined = auto, null = disabled, string = explicit
|
|
91
|
+
appCli: undefined, // undefined = auto, null = disabled, string = explicit
|
|
92
|
+
apiServerToken: null,
|
|
93
|
+
graph: null,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function needValue(i, name) {
|
|
97
|
+
if (i + 1 >= args.length) {
|
|
98
|
+
console.error(`Error: ${name} requires a value.`);
|
|
99
|
+
process.exit(2);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let subcommand = null;
|
|
104
|
+
for (let i = 0; i < args.length; i++) {
|
|
105
|
+
const arg = args[i];
|
|
106
|
+
if (arg === "--help" || arg === "-h" || (subcommand === null && positional.length === 0 && arg === "help")) {
|
|
107
|
+
console.log(USAGE);
|
|
108
|
+
process.exit(0);
|
|
109
|
+
} else if (arg === "--watch") {
|
|
110
|
+
// Watch is the default now; the flag stays so old command lines keep working.
|
|
111
|
+
} else if (arg === "--once") {
|
|
112
|
+
flags.once = true;
|
|
113
|
+
} else if (arg === "--git-commit") {
|
|
114
|
+
flags.gitCommit = true;
|
|
115
|
+
} else if (arg === "--no-git-commit") {
|
|
116
|
+
flags.gitCommit = false;
|
|
117
|
+
} else if (arg === "--yes") {
|
|
118
|
+
flags.yes = true;
|
|
119
|
+
} else if (arg === "--no-backup") {
|
|
120
|
+
flags.backup = false;
|
|
121
|
+
} else if (arg === "--two-way") {
|
|
122
|
+
flags.twoWay = true;
|
|
123
|
+
} else if (arg === "--mirror") {
|
|
124
|
+
flags.mirror = true;
|
|
125
|
+
} else if (arg === "--overwrite-unmanaged") {
|
|
126
|
+
flags.overwriteUnmanaged = true;
|
|
127
|
+
} else if (arg === "--markdown") {
|
|
128
|
+
needValue(i, "--markdown");
|
|
129
|
+
flags.markdown = args[++i];
|
|
130
|
+
} else if (arg === "--no-markdown") {
|
|
131
|
+
// `false`, not `null`: null is "not specified", which now MEANS the vault
|
|
132
|
+
// root. Off has to be sayable separately from unsaid.
|
|
133
|
+
flags.markdown = false;
|
|
134
|
+
} else if (arg === "--no-signal") {
|
|
135
|
+
flags.signal = null;
|
|
136
|
+
} else if (arg === "--no-app-cli") {
|
|
137
|
+
flags.appCli = null;
|
|
138
|
+
} else if (arg === "--interval") {
|
|
139
|
+
needValue(i, "--interval");
|
|
140
|
+
const rawVal = args[++i];
|
|
141
|
+
const val = Number(rawVal);
|
|
142
|
+
if (!Number.isInteger(val) || val <= 0) {
|
|
143
|
+
console.error(`Error: --interval must be a positive integer >= 1 (got "${rawVal}").`);
|
|
144
|
+
process.exit(2);
|
|
145
|
+
}
|
|
146
|
+
flags.interval = val;
|
|
147
|
+
} else if (arg === "--message") {
|
|
148
|
+
needValue(i, "--message");
|
|
149
|
+
flags.message = args[++i];
|
|
150
|
+
} else if (arg === "--signal") {
|
|
151
|
+
needValue(i, "--signal");
|
|
152
|
+
flags.signal = args[++i];
|
|
153
|
+
} else if (arg === "--app-cli") {
|
|
154
|
+
needValue(i, "--app-cli");
|
|
155
|
+
flags.appCli = args[++i];
|
|
156
|
+
} else if (arg === "--graph") {
|
|
157
|
+
needValue(i, "--graph");
|
|
158
|
+
flags.graph = args[++i];
|
|
159
|
+
} else if (arg === "--api-server-token") {
|
|
160
|
+
needValue(i, "--api-server-token");
|
|
161
|
+
flags.apiServerToken = args[++i];
|
|
162
|
+
} else if (arg.startsWith("-")) {
|
|
163
|
+
// One dash included: "-graph demo" once sailed through as a graph literally
|
|
164
|
+
// named "-graph" and a vault named "demo" — a typo must stop, not sync.
|
|
165
|
+
console.error(`Error: Unknown flag "${arg}". Run \`logseq-sync --help\` for usage.`);
|
|
166
|
+
process.exit(2);
|
|
167
|
+
} else if (subcommand === null && positional.length === 0 && (arg === "doctor" || arg === "restore")) {
|
|
168
|
+
subcommand = arg;
|
|
169
|
+
} else {
|
|
170
|
+
positional.push(arg);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const probe = {
|
|
175
|
+
platform: process.platform,
|
|
176
|
+
env: process.env,
|
|
177
|
+
home: process.env.HOME || process.env.USERPROFILE || homedir(),
|
|
178
|
+
exists: existsSync,
|
|
179
|
+
read: (p) => readFileSync(p, "utf8"),
|
|
180
|
+
listDir: (p) => {
|
|
181
|
+
try {
|
|
182
|
+
return readdirSync(p, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
183
|
+
} catch {
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const settings = pluginSettings(probe);
|
|
190
|
+
|
|
191
|
+
// A shell expands ~ before the watcher ever sees it; a text field in Logseq's
|
|
192
|
+
// settings panel does not, and resolve("~/vault") would quietly create a
|
|
193
|
+
// directory literally named "~" beside the working directory.
|
|
194
|
+
function expandHome(p) {
|
|
195
|
+
if (!p) return p;
|
|
196
|
+
if (p === "~") return probe.home;
|
|
197
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) return join(probe.home, p.slice(2));
|
|
198
|
+
return p;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- how to export --------------------------------------------------------
|
|
202
|
+
const apiServerToken = (flags.apiServerToken || process.env.LOGSEQ_API_SERVER_TOKEN || "").trim() || null;
|
|
203
|
+
|
|
204
|
+
function resolveAppCli() {
|
|
205
|
+
if (flags.appCli === null) return null; // --no-app-cli
|
|
206
|
+
const explicit = flags.appCli || (process.env.LOGSEQ_APP_CLI || "").trim() || null;
|
|
207
|
+
if (explicit) {
|
|
208
|
+
if (/\.(cmd|bat)$/i.test(explicit)) {
|
|
209
|
+
// If it is the launcher the app generated, read the paths out of it
|
|
210
|
+
// rather than refusing something that is perfectly usable.
|
|
211
|
+
const parsed = parseManagedShim(probe, explicit);
|
|
212
|
+
if (parsed) {
|
|
213
|
+
if (apiServerToken) {
|
|
214
|
+
console.error(
|
|
215
|
+
"Error: --app-cli and --api-server-token are mutually exclusive — the app CLI reaches the running app directly, so it needs no token."
|
|
216
|
+
);
|
|
217
|
+
process.exit(2);
|
|
218
|
+
}
|
|
219
|
+
return parsed;
|
|
220
|
+
}
|
|
221
|
+
console.error(
|
|
222
|
+
`Error: --app-cli "${explicit}" is a .cmd/.bat shim; Node cannot run one without a shell. ` +
|
|
223
|
+
`Point --app-cli at the Logseq executable itself.`
|
|
224
|
+
);
|
|
225
|
+
process.exit(2);
|
|
226
|
+
}
|
|
227
|
+
if (apiServerToken) {
|
|
228
|
+
console.error(
|
|
229
|
+
"Error: --app-cli and --api-server-token are mutually exclusive — the app CLI reaches the running app directly, so it needs no token."
|
|
230
|
+
);
|
|
231
|
+
process.exit(2);
|
|
232
|
+
}
|
|
233
|
+
return { command: explicit, argsPrefix: [], env: {}, how: "given with --app-cli" };
|
|
234
|
+
}
|
|
235
|
+
// Auto-detection happens at the call site, where a candidate can be verified
|
|
236
|
+
// by actually using it. An explicit token selects the fallback transport, so
|
|
237
|
+
// there is nothing to detect.
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function runVia(candidate, cmdArgs) {
|
|
242
|
+
return execFileSync(candidate.command, [...candidate.argsPrefix, ...cmdArgs], {
|
|
243
|
+
encoding: "utf8",
|
|
244
|
+
shell: false,
|
|
245
|
+
maxBuffer: 1 << 24,
|
|
246
|
+
env: { ...process.env, ...candidate.env },
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Finding the CLI and asking it which graph to sync are the same step: a
|
|
251
|
+
// candidate that answers `graph list` is, by that fact, the working one. So we
|
|
252
|
+
// verify by doing the work rather than by trusting a path — which is the only
|
|
253
|
+
// honest way to behave on an OS or Logseq version this has never run on. The
|
|
254
|
+
// filesystem heuristics stay as the fallback for when there is no CLI at all.
|
|
255
|
+
let appCli = resolveAppCli();
|
|
256
|
+
let detected = null;
|
|
257
|
+
|
|
258
|
+
if (appCli) {
|
|
259
|
+
detected = detectGraphViaCli((cmdArgs) => runVia(appCli, cmdArgs));
|
|
260
|
+
} else if (flags.appCli !== null && !apiServerToken) {
|
|
261
|
+
const candidates = appCliCandidates(probe);
|
|
262
|
+
for (const candidate of candidates) {
|
|
263
|
+
const answer = detectGraphViaCli((cmdArgs) => runVia(candidate, cmdArgs));
|
|
264
|
+
if (answer) {
|
|
265
|
+
appCli = candidate;
|
|
266
|
+
detected = answer;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// None answered: keep the best-ranked one anyway, so the export fails with
|
|
271
|
+
// that CLI's own error instead of a vague "no CLI found".
|
|
272
|
+
if (!appCli) appCli = candidates[0] ?? null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!detected) detected = detectGraph(probe);
|
|
276
|
+
const knownGraphs = detected?.graphs ?? [];
|
|
277
|
+
let graphName = flags.graph;
|
|
278
|
+
let vaultRaw = null;
|
|
279
|
+
|
|
280
|
+
if (positional.length >= 2) {
|
|
281
|
+
if (!graphName) graphName = positional[0];
|
|
282
|
+
vaultRaw = positional[1];
|
|
283
|
+
} else if (positional.length === 1) {
|
|
284
|
+
const only = positional[0];
|
|
285
|
+
const looksLikePath = only.includes("/") || only.includes(sep) || only.startsWith(".") || only.startsWith("~");
|
|
286
|
+
const isGraphName =
|
|
287
|
+
!looksLikePath &&
|
|
288
|
+
(detected?.name === only || (detected?.candidates ?? []).includes(only));
|
|
289
|
+
if (isGraphName) {
|
|
290
|
+
console.error(
|
|
291
|
+
`Error: "${only}" is the name of a graph, not a vault directory. ` +
|
|
292
|
+
`Write the destination too — logseq-sync ${only} <vault-dir> — or select it with --graph ${only}.`
|
|
293
|
+
);
|
|
294
|
+
process.exit(2);
|
|
295
|
+
}
|
|
296
|
+
vaultRaw = only;
|
|
297
|
+
} else {
|
|
298
|
+
vaultRaw = settings.vaultPath || null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function resolveGraphOrExit() {
|
|
302
|
+
if (graphName) return graphName;
|
|
303
|
+
if (detected?.name) return detected.name;
|
|
304
|
+
if (detected?.candidates) {
|
|
305
|
+
console.error(
|
|
306
|
+
`Error: several graphs and none open in the app — ${detected.candidates.join(", ")}. ` +
|
|
307
|
+
`Pick one with --graph <name>. Run \`logseq-sync doctor\` for the full picture.`
|
|
308
|
+
);
|
|
309
|
+
process.exit(2);
|
|
310
|
+
}
|
|
311
|
+
console.error(
|
|
312
|
+
`Error: no Logseq graphs found under ${join(logseqRootDir(probe), "graphs")}. ` +
|
|
313
|
+
`Open a graph in Logseq first. Run \`logseq-sync doctor\` for the full picture.`
|
|
314
|
+
);
|
|
315
|
+
process.exit(2);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function resolveVaultOrExit() {
|
|
319
|
+
if (vaultRaw) return resolve(expandHome(vaultRaw));
|
|
320
|
+
console.error(
|
|
321
|
+
`Error: no vault directory. Set it in Logseq — Settings → Plugins → ${PLUGIN_TITLE} → ` +
|
|
322
|
+
`"Vault folder" — or pass one: logseq-sync <vault-dir>. ` +
|
|
323
|
+
`Run \`logseq-sync doctor\` for the full picture.`
|
|
324
|
+
);
|
|
325
|
+
process.exit(2);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
function resolveSignalPath() {
|
|
331
|
+
if (flags.signal === null) return null; // --no-signal
|
|
332
|
+
if (typeof flags.signal === "string") return resolve(expandHome(flags.signal));
|
|
333
|
+
const auto = signalFilePath(probe);
|
|
334
|
+
// The storage directory only appears once the plugin has written something,
|
|
335
|
+
// so its absence proves nothing. Gate on the dotdir instead: if Logseq is
|
|
336
|
+
// installed at all, writing status there is right — and it is already there
|
|
337
|
+
// for the plugin to read the moment it loads.
|
|
338
|
+
return existsSync(logseqDotDir(probe)) ? auto : null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function redact(text) {
|
|
342
|
+
const str = String(text ?? "");
|
|
343
|
+
return apiServerToken ? str.split(apiServerToken).join("***") : str;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --- doctor ---------------------------------------------------------------
|
|
347
|
+
function doctor() {
|
|
348
|
+
const dotDir = logseqDotDir(probe);
|
|
349
|
+
const rows = [];
|
|
350
|
+
let blocked = false;
|
|
351
|
+
|
|
352
|
+
const mark = (ok, label, detail) => {
|
|
353
|
+
rows.push(`${ok ? " ok " : " MISS "} ${label.padEnd(14)} ${detail}`);
|
|
354
|
+
if (!ok) blocked = true;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
mark(existsSync(dotDir), "Logseq dotdir", dotDir);
|
|
358
|
+
|
|
359
|
+
const storage = join(dotDir, "storages", PLUGIN_ID);
|
|
360
|
+
const pluginInstalled = existsSync(storage);
|
|
361
|
+
rows.push(
|
|
362
|
+
`${pluginInstalled ? " ok " : " note " } ${"plugin".padEnd(14)} ` +
|
|
363
|
+
(pluginInstalled
|
|
364
|
+
? storage
|
|
365
|
+
: `not installed yet (no ${storage}) — sync still works, the toolbar just will not update`)
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
if (appCli) {
|
|
369
|
+
mark(true, "app CLI", `${appCli.command} (${appCli.how})`);
|
|
370
|
+
} else if (apiServerToken) {
|
|
371
|
+
rows.push(` ok ${"export".padEnd(14)} @logseq/cli through the app's API server`);
|
|
372
|
+
} else {
|
|
373
|
+
mark(false, "app CLI", "no Logseq CLI found on PATH or in the app bundle — install Logseq, or pass --app-cli <path>");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (graphName) mark(true, "graph", `${graphName} (given)`);
|
|
377
|
+
else if (detected?.name) mark(true, "graph", `${detected.name} (${detected.how})`);
|
|
378
|
+
else if (detected?.candidates) mark(false, "graph", `ambiguous: ${detected.candidates.join(", ")} — pick one with --graph`);
|
|
379
|
+
else mark(false, "graph", `none found under ${join(logseqRootDir(probe), "graphs")}`);
|
|
380
|
+
|
|
381
|
+
if (vaultRaw) mark(true, "vault", resolve(expandHome(vaultRaw)));
|
|
382
|
+
else mark(false, "vault", `unset — Settings → Plugins → ${PLUGIN_TITLE} → "Vault folder", or pass one as an argument`);
|
|
383
|
+
|
|
384
|
+
// Only a run that will actually commit needs an author.
|
|
385
|
+
const wouldCommit =
|
|
386
|
+
flags.gitCommit === true ||
|
|
387
|
+
(flags.gitCommit !== false && vaultRaw && existsSync(resolve(expandHome(vaultRaw))) && isGitRepo(resolve(expandHome(vaultRaw))));
|
|
388
|
+
if (wouldCommit) {
|
|
389
|
+
try {
|
|
390
|
+
// Probe where the commit will actually run: identity can come from the
|
|
391
|
+
// vault's own repo config, and asking from anywhere else (say, a source
|
|
392
|
+
// checkout that has one) answers a different question.
|
|
393
|
+
const where = vaultRaw && existsSync(resolve(expandHome(vaultRaw))) ? resolve(expandHome(vaultRaw)) : tmpdir();
|
|
394
|
+
execFileSync("git", ["var", "GIT_AUTHOR_IDENT"], { cwd: where, stdio: "ignore", shell: false });
|
|
395
|
+
mark(true, "git identity", "configured");
|
|
396
|
+
} catch {
|
|
397
|
+
mark(
|
|
398
|
+
false,
|
|
399
|
+
"git identity",
|
|
400
|
+
'git has no author configured, so commits will fail — `git config --global user.name "..."` ' +
|
|
401
|
+
"and user.email, or run with --no-git-commit"
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const sig = resolveSignalPath();
|
|
407
|
+
rows.push(`${sig ? " ok " : " note "} ${"bridge".padEnd(14)} ${sig ?? "no plugin storage dir; interval polling only"}`);
|
|
408
|
+
|
|
409
|
+
console.log(`${PLUGIN_TITLE} — logseq-sync doctor\n`);
|
|
410
|
+
console.log(rows.join("\n"));
|
|
411
|
+
console.log(
|
|
412
|
+
blocked
|
|
413
|
+
? "\nNot ready: fix the MISS lines above."
|
|
414
|
+
: "\nReady. Run `logseq-sync` with no arguments to start syncing."
|
|
415
|
+
);
|
|
416
|
+
process.exit(blocked ? 1 : 0);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (subcommand === "doctor") doctor();
|
|
420
|
+
|
|
421
|
+
// --- resolved, from here on -----------------------------------------------
|
|
422
|
+
graphName = resolveGraphOrExit();
|
|
423
|
+
|
|
424
|
+
// Validate graph name to prevent command/path injection
|
|
425
|
+
// The first character must not be a dash or a dot: the name travels as argv
|
|
426
|
+
// into the exporting CLI, where a leading dash reads as a flag ("-graph"
|
|
427
|
+
// arrived here as a real user typo for --graph), and "." / ".." read as paths.
|
|
428
|
+
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(graphName)) {
|
|
429
|
+
console.error(`Error: Invalid graph name "${graphName}". Only alphanumeric characters, hyphens, and underscores are allowed.`);
|
|
430
|
+
process.exit(2);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// `logseq graph export --graph <name>` does not fail on an unknown name — it
|
|
434
|
+
// CREATES that graph and exports the empty result. A typo would then sync
|
|
435
|
+
// emptiness over the vault and commit it. Refuse names we cannot see,
|
|
436
|
+
// INCLUDING when we see none at all: a bare run calls zero graphs a hard
|
|
437
|
+
// error, and naming one does not make graphs exist — treating the same state
|
|
438
|
+
// as "cannot verify, proceed" is how `--graph demo` once sailed past this
|
|
439
|
+
// check straight into the vault error, and reads as a contradiction. The
|
|
440
|
+
// escape hatches are real, not hypothetical: LOGSEQ_ROOT_DIR when the graphs
|
|
441
|
+
// live elsewhere, and the API-server route, which exports whatever graph the
|
|
442
|
+
// app has open and ignores local directories entirely.
|
|
443
|
+
if (!apiServerToken && !knownGraphs.includes(graphName)) {
|
|
444
|
+
console.error(
|
|
445
|
+
knownGraphs.length > 0
|
|
446
|
+
? `Error: no graph named "${graphName}" — found ${knownGraphs.join(", ")}. ` +
|
|
447
|
+
`(The app CLI would silently create "${graphName}" rather than fail.) ` +
|
|
448
|
+
`Run \`logseq-sync doctor\` for the full picture.`
|
|
449
|
+
: `Error: no graph named "${graphName}" — no graphs found under ` +
|
|
450
|
+
`${join(logseqRootDir(probe), "graphs")} at all, and the app CLI would silently ` +
|
|
451
|
+
`create "${graphName}" and sync emptiness. If your graphs live elsewhere, set ` +
|
|
452
|
+
`LOGSEQ_ROOT_DIR. Run \`logseq-sync doctor\` for the full picture.`
|
|
453
|
+
);
|
|
454
|
+
process.exit(2);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// The vault is what the person chose and what Logseq opens; the GEML tree lives
|
|
458
|
+
// in a dot directory beneath it. Dot-prefixed so Logseq's file-graph indexer
|
|
459
|
+
// walks past it, named after the plugin so a directory listing says who owns it.
|
|
460
|
+
// Before this, the vault WAS the GEML tree, and the first person to set it up
|
|
461
|
+
// asked "why is it all geml and no markdown?" — the layout taught the wrong model.
|
|
462
|
+
const GEML_DIR = ".logseq-sync-vault-with-geml";
|
|
463
|
+
const vaultDir = resolveVaultOrExit();
|
|
464
|
+
const targetDir = join(vaultDir, GEML_DIR);
|
|
465
|
+
// Absent flag = the vault root: Markdown is what "Vault folder" promises. The
|
|
466
|
+
// flag's meaning moved from "turn this on" to "write it somewhere else", and
|
|
467
|
+
// `--no-markdown` is the off switch it never had.
|
|
468
|
+
const markdownDir =
|
|
469
|
+
flags.markdown === false ? null
|
|
470
|
+
: flags.markdown ? resolve(expandHome(flags.markdown))
|
|
471
|
+
: vaultDir;
|
|
472
|
+
const cliCwd = process.env.LOGSEQ_CLI_DIR ?? process.cwd();
|
|
473
|
+
// The flag wins over the setting for this run, the same precedence a vault path
|
|
474
|
+
// passed as an argument already has. The setting is a sentence, not a boolean,
|
|
475
|
+
// because it is read by a person in a settings panel — only the overwrite
|
|
476
|
+
// choice is matched, so an unrecognised value keeps the safe behaviour.
|
|
477
|
+
const overwriteUnmanaged =
|
|
478
|
+
flags.overwriteUnmanaged !== undefined
|
|
479
|
+
? flags.overwriteUnmanaged
|
|
480
|
+
: /^overwrite/i.test(String(settings.unmanagedFiles ?? ""));
|
|
481
|
+
const signalPath = resolveSignalPath();
|
|
482
|
+
const watchMode = !flags.once;
|
|
483
|
+
const gitCommit = subcommand === "restore" ? false : resolveGitCommit();
|
|
484
|
+
|
|
485
|
+
if (flags.twoWay && !appCli) {
|
|
486
|
+
console.error(
|
|
487
|
+
"Error: --two-way needs the Logseq desktop app's CLI (it performs the imports). " +
|
|
488
|
+
"Install Logseq, or pass --app-cli <path>. Run `logseq-sync doctor` for the full picture."
|
|
489
|
+
);
|
|
490
|
+
process.exit(2);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function isGitRepo(dir) {
|
|
494
|
+
try {
|
|
495
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
496
|
+
cwd: dir, stdio: "ignore", shell: false,
|
|
497
|
+
});
|
|
498
|
+
return true;
|
|
499
|
+
} catch {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Whether this run commits. A vault can be somebody's Dropbox or iCloud folder;
|
|
506
|
+
* turning it into a git repository is not a decision to make on their behalf.
|
|
507
|
+
* So: commit into a repository that already exists, create one only when asked.
|
|
508
|
+
*/
|
|
509
|
+
function resolveGitCommit() {
|
|
510
|
+
if (flags.gitCommit === false) return false;
|
|
511
|
+
// The VAULT is the repository, not the GEML tree inside it: a repo rooted at
|
|
512
|
+
// the dot directory would version the source of truth and leave every
|
|
513
|
+
// Markdown page — the half a person reads and edits — untracked.
|
|
514
|
+
mkdirSync(vaultDir, { recursive: true });
|
|
515
|
+
if (isGitRepo(vaultDir)) return true;
|
|
516
|
+
if (flags.gitCommit === true) {
|
|
517
|
+
try {
|
|
518
|
+
execFileSync("git", ["init", "-q"], { cwd: vaultDir, stdio: "ignore", shell: false });
|
|
519
|
+
console.log(`Initialised a git repository in ${vaultDir}`);
|
|
520
|
+
return true;
|
|
521
|
+
} catch (err) {
|
|
522
|
+
console.error(`Could not initialise a git repository in ${vaultDir}: ${redact(err.message)}`);
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// The status file lands beside the signal file — the plugin's storage
|
|
530
|
+
// directory — the one place logseq.FileStorage.getItem can read it back from.
|
|
531
|
+
function writeStatus(status) {
|
|
532
|
+
if (!signalPath) return;
|
|
533
|
+
try {
|
|
534
|
+
mkdirSync(dirname(signalPath), { recursive: true });
|
|
535
|
+
atomicWriteFileSync(join(dirname(signalPath), STATUS_FILE), JSON.stringify(status, null, 1) + "\n");
|
|
536
|
+
} catch (err) {
|
|
537
|
+
console.error(`Could not write status file: ${redact(err.message)}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Find @logseq/cli entry point or run via npx without shell: true
|
|
542
|
+
function runLogseqCli(...cmdArgs) {
|
|
543
|
+
const directCliPath = resolve(cliCwd, "node_modules", "@logseq", "cli", "cli.mjs");
|
|
544
|
+
if (existsSync(directCliPath)) {
|
|
545
|
+
return execFileSync(process.execPath, [directCliPath, ...cmdArgs], {
|
|
546
|
+
cwd: cliCwd,
|
|
547
|
+
encoding: "utf8",
|
|
548
|
+
shell: false,
|
|
549
|
+
maxBuffer: 1 << 28,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// On Windows the npx fallback is an instruction, not a spawn: Node refuses
|
|
554
|
+
// to run a .cmd without a shell (CVE-2024-27980), and routing a user-typed
|
|
555
|
+
// graph name through cmd.exe to get around that is how injection happens.
|
|
556
|
+
// Every prior attempt died as `spawnSync npx.cmd EINVAL`, once every poll.
|
|
557
|
+
if (process.platform === "win32") {
|
|
558
|
+
throw new Error(
|
|
559
|
+
"@logseq/cli is not installed where I can see it. Install it once\n" +
|
|
560
|
+
" (mkdir logseq-cli && cd logseq-cli && npm init -y && npm i @logseq/cli)\n" +
|
|
561
|
+
"and point LOGSEQ_CLI_DIR at that directory — or pass --app-cli <path>\n" +
|
|
562
|
+
"to the desktop app's CLI."
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
return execFileSync("npx", ["-y", "@logseq/cli", ...cmdArgs], {
|
|
566
|
+
cwd: cliCwd,
|
|
567
|
+
encoding: "utf8",
|
|
568
|
+
shell: false,
|
|
569
|
+
maxBuffer: 1 << 28,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// 2.0 renamed the human-readable graph export: :graph now means the datoms
|
|
574
|
+
// dump, and :graph-human is the {:pages-and-blocks ...} shape this converter
|
|
575
|
+
// reads. Verified against the 2.0.1 app bundle.
|
|
576
|
+
function appCliRun(...cmdArgs) {
|
|
577
|
+
return execFileSync(
|
|
578
|
+
appCli.command,
|
|
579
|
+
[...appCli.argsPrefix, ...cmdArgs],
|
|
580
|
+
{ encoding: "utf8", shell: false, maxBuffer: 1 << 28, env: { ...process.env, ...appCli.env } }
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function runAppCli(outFile) {
|
|
585
|
+
return appCliRun(
|
|
586
|
+
"graph", "export", "--graph", graphName, "--type", "edn", "--file", outFile,
|
|
587
|
+
"-e", "{:export-type :graph-human}"
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** How many pages a directory holds — the only sanity check worth running before an import. */
|
|
592
|
+
function countVaultPages(dir) {
|
|
593
|
+
let n = 0;
|
|
594
|
+
for (const sub of ["pages", "journals"]) {
|
|
595
|
+
try {
|
|
596
|
+
n += readdirSync(join(dir, sub)).filter((f) => f.endsWith(".geml")).length;
|
|
597
|
+
} catch {}
|
|
598
|
+
}
|
|
599
|
+
return n;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Vault ➔ graph. The one direction that writes into somebody's notes, so it
|
|
604
|
+
* rehearses by default and takes the app's own backup before it commits to
|
|
605
|
+
* anything.
|
|
606
|
+
*/
|
|
607
|
+
async function restore() {
|
|
608
|
+
const pages = countVaultPages(targetDir);
|
|
609
|
+
if (pages === 0) {
|
|
610
|
+
console.error(
|
|
611
|
+
`Error: no pages found in ${targetDir} — expected .geml files under pages/ or journals/ ` +
|
|
612
|
+
`inside ${GEML_DIR}/. That directory is the source of truth; the Markdown at the vault ` +
|
|
613
|
+
`root is a one-way copy and restore never reads it. Not a vault this can restore from.`
|
|
614
|
+
);
|
|
615
|
+
process.exit(2);
|
|
616
|
+
}
|
|
617
|
+
if (!appCli) {
|
|
618
|
+
console.error(
|
|
619
|
+
"Error: restore needs the Logseq desktop app's CLI (it performs the import). Install Logseq, or pass --app-cli <path>."
|
|
620
|
+
);
|
|
621
|
+
process.exit(2);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
console.log(`Restore: ${targetDir} (${pages} pages) ➔ graph "${graphName}"`);
|
|
625
|
+
|
|
626
|
+
if (!flags.yes) {
|
|
627
|
+
console.log(
|
|
628
|
+
`\nThis is a rehearsal — nothing has been written.\n` +
|
|
629
|
+
`Re-run with --yes to import, which will:\n` +
|
|
630
|
+
(flags.backup ? ` 1. take a Logseq backup of "${graphName}"\n 2. ` : " 1. ") +
|
|
631
|
+
`import ${pages} pages into "${graphName}", merging by block uuid.`
|
|
632
|
+
);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (flags.backup) {
|
|
637
|
+
try {
|
|
638
|
+
appCliRun("graph", "backup", "create", "--graph", graphName);
|
|
639
|
+
console.log(` Backed up "${graphName}" first.`);
|
|
640
|
+
} catch (err) {
|
|
641
|
+
console.error(`Error: backup failed, so the import was NOT attempted: ${redact(err.message)}`);
|
|
642
|
+
process.exit(1);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const tmpEdn = join(tmpdir(), `geml-restore-${process.pid}-${randomUUID()}.edn`);
|
|
647
|
+
try {
|
|
648
|
+
atomicWriteFileSync(tmpEdn, syncDiskToEdn(targetDir, { parse: parseGeml, addressedUnits, sliceUnit }));
|
|
649
|
+
appCliRun("graph", "import", "--graph", graphName, "--type", "edn", "--input", tmpEdn);
|
|
650
|
+
console.log(` Imported ${pages} pages into "${graphName}".`);
|
|
651
|
+
} catch (err) {
|
|
652
|
+
console.error(`Restore failed: ${redact(err.message)}`);
|
|
653
|
+
process.exit(1);
|
|
654
|
+
} finally {
|
|
655
|
+
if (existsSync(tmpEdn)) { try { unlinkSync(tmpEdn); } catch {} }
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
let lastEdnHash = null;
|
|
660
|
+
|
|
661
|
+
// ⑤'s bookkeeping: a graph backup before the session's first import, then
|
|
662
|
+
// every BACKUP_EVERY imports after — enough that an import gone wrong always
|
|
663
|
+
// has a recent restore point, without one backup per keystroke.
|
|
664
|
+
let sessionBackupTaken = false;
|
|
665
|
+
let importsSinceBackup = 0;
|
|
666
|
+
const BACKUP_EVERY = 10;
|
|
667
|
+
|
|
668
|
+
// Export the graph as EDN into tempPath — the one exporter, used once per
|
|
669
|
+
// cycle, twice when a two-way import changed the graph mid-cycle.
|
|
670
|
+
// With a token the CLI goes through the running app's HTTP API server and
|
|
671
|
+
// exports whatever graph the app has OPEN — the graph name is not part of
|
|
672
|
+
// that request, so -a REPLACES -g rather than joining it. Without a token
|
|
673
|
+
// the CLI opens the named graph's sqlite directly, which only works while
|
|
674
|
+
// the app does not hold the lock on it.
|
|
675
|
+
function exportGraphEdn(tempPath) {
|
|
676
|
+
if (appCli) {
|
|
677
|
+
runAppCli(tempPath);
|
|
678
|
+
} else {
|
|
679
|
+
const exportSource = apiServerToken ? ["-a", apiServerToken] : ["-g", graphName];
|
|
680
|
+
runLogseqCli("export-edn", ...exportSource, "-f", tempPath);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// The import half of --two-way, run before the export lands on disk: whatever
|
|
685
|
+
// a person or agent changed in the vault goes back into the graph first, so
|
|
686
|
+
// the write that follows holds the merged state and re-baselines the
|
|
687
|
+
// manifest. Deletions are reported, never imported (the vault's stance, now
|
|
688
|
+
// in both directions); a file changed on BOTH sides since the last sync is a
|
|
689
|
+
// conflict — importing it would clobber the graph's edit, exporting over it
|
|
690
|
+
// would clobber the person's, so two-way does neither and says so until a
|
|
691
|
+
// person merges.
|
|
692
|
+
async function importExternalEdits(ednText) {
|
|
693
|
+
const graphFiles = ednToGemlFiles(ednText);
|
|
694
|
+
const edits = detectExternalEdits(targetDir, { graphFiles });
|
|
695
|
+
if (!edits.baselineKnown) {
|
|
696
|
+
// A v1 manifest (or none) has no content baseline — the sync about to run
|
|
697
|
+
// writes one, and the NEXT cycle can start importing.
|
|
698
|
+
return { imported: 0, conflicts: [], missing: [] };
|
|
699
|
+
}
|
|
700
|
+
const importable = [...edits.modified, ...edits.added];
|
|
701
|
+
const result = { imported: 0, conflicts: edits.conflicts, missing: edits.missing };
|
|
702
|
+
if (edits.missing.length > 0) {
|
|
703
|
+
console.log(
|
|
704
|
+
` two-way: ${edits.missing.length} vault file(s) deleted on disk — deletions are never imported; ` +
|
|
705
|
+
`delete the page in Logseq if you mean it.`
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
if (importable.length === 0) return result;
|
|
709
|
+
|
|
710
|
+
if (!sessionBackupTaken || importsSinceBackup >= BACKUP_EVERY) {
|
|
711
|
+
appCliRun("graph", "backup", "create", "--graph", graphName);
|
|
712
|
+
sessionBackupTaken = true;
|
|
713
|
+
importsSinceBackup = 0;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const tmpEdn = join(tmpdir(), `geml-twoway-${process.pid}-${randomUUID()}.edn`);
|
|
717
|
+
try {
|
|
718
|
+
atomicWriteFileSync(
|
|
719
|
+
tmpEdn,
|
|
720
|
+
syncDiskToEdn(targetDir, { parse: parseGeml, addressedUnits, sliceUnit }, { exclude: edits.conflicts })
|
|
721
|
+
);
|
|
722
|
+
appCliRun("graph", "import", "--graph", graphName, "--type", "edn", "--input", tmpEdn);
|
|
723
|
+
} finally {
|
|
724
|
+
if (existsSync(tmpEdn)) { try { unlinkSync(tmpEdn); } catch {} }
|
|
725
|
+
}
|
|
726
|
+
importsSinceBackup += 1;
|
|
727
|
+
result.imported = importable.length;
|
|
728
|
+
console.log(
|
|
729
|
+
`[${new Date().toLocaleTimeString()}] two-way: imported ${importable.length} vault edit(s) into "${graphName}"` +
|
|
730
|
+
(edits.conflicts.length ? `; ${edits.conflicts.length} conflict(s) held` : "") +
|
|
731
|
+
`.`
|
|
732
|
+
);
|
|
733
|
+
return result;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function performSync() {
|
|
737
|
+
const tempEdnPath = join(tmpdir(), `logseq-export-${process.pid}-${Date.now()}-${randomUUID()}.edn`);
|
|
738
|
+
try {
|
|
739
|
+
// 1. Export from Logseq DB via official CLI.
|
|
740
|
+
exportGraphEdn(tempEdnPath);
|
|
741
|
+
|
|
742
|
+
if (!existsSync(tempEdnPath)) {
|
|
743
|
+
throw new Error(`Export failed: ${tempEdnPath} was not created.`);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const stat = statSync(tempEdnPath);
|
|
747
|
+
if (stat.size === 0) {
|
|
748
|
+
throw new Error(`Export produced an empty (0 byte) EDN file.`);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
let ednText = readFileSync(tempEdnPath, "utf8");
|
|
752
|
+
|
|
753
|
+
// 1.5 Two-way import, BEFORE the unchanged-export short-circuit below:
|
|
754
|
+
// the graph being unchanged says nothing about the vault.
|
|
755
|
+
let twoWay = null;
|
|
756
|
+
if (flags.twoWay) {
|
|
757
|
+
twoWay = await importExternalEdits(ednText);
|
|
758
|
+
if (twoWay.imported > 0) {
|
|
759
|
+
// The graph just absorbed the vault edits — export again, so the disk
|
|
760
|
+
// write and the manifest baseline hold the merged state.
|
|
761
|
+
exportGraphEdn(tempEdnPath);
|
|
762
|
+
ednText = readFileSync(tempEdnPath, "utf8");
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const twoWayActivity =
|
|
766
|
+
twoWay !== null && (twoWay.imported > 0 || twoWay.conflicts.length > 0 || twoWay.missing.length > 0);
|
|
767
|
+
|
|
768
|
+
// 2. Efficiency: In watch mode, skip disk scanning if export content is bit-for-bit identical
|
|
769
|
+
const currentHash = createHash("sha256").update(ednText).digest("hex");
|
|
770
|
+
if (watchMode && currentHash === lastEdnHash && !twoWayActivity) {
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// 3. Incremental sync to disk
|
|
775
|
+
const res = await syncEdnToDisk(ednText, targetDir, {
|
|
776
|
+
autoCommit: gitCommit,
|
|
777
|
+
deleteOrphans: flags.mirror,
|
|
778
|
+
overwriteUnmanaged,
|
|
779
|
+
preserve: twoWay?.conflicts ?? [],
|
|
780
|
+
markdownDir,
|
|
781
|
+
// The repo is the vault, so a commit carries both trees (see sync-engine).
|
|
782
|
+
gitDir: vaultDir,
|
|
783
|
+
lib: gemlLib,
|
|
784
|
+
commitMessage: flags.message || `logseq-geml: sync graph "${graphName}" (${new Date().toISOString()})`,
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// Files that were on disk before this sync ever ran. Named, never counted
|
|
788
|
+
// as written: silence here is how a person's own graph gets eaten.
|
|
789
|
+
const heldBack = [...(res.unmanaged ?? []), ...(res.markdownUnmanaged ?? [])];
|
|
790
|
+
// The other half of the same choice. Overwriting is allowed; doing it
|
|
791
|
+
// quietly is not — the list of files somebody's edit just left is the input
|
|
792
|
+
// their next step needs, and it exists only if it is printed.
|
|
793
|
+
const takenOver = [...(res.overwritten ?? []), ...(res.markdownOverwritten ?? [])];
|
|
794
|
+
|
|
795
|
+
lastEdnHash = currentHash;
|
|
796
|
+
writeStatus({
|
|
797
|
+
ok: true,
|
|
798
|
+
at: new Date().toISOString(),
|
|
799
|
+
graph: graphName,
|
|
800
|
+
written: res.written.length,
|
|
801
|
+
unchanged: res.unchanged.length,
|
|
802
|
+
orphaned: res.orphaned.length,
|
|
803
|
+
deleted: res.deleted.length,
|
|
804
|
+
imported: twoWay?.imported ?? 0,
|
|
805
|
+
conflicts: twoWay?.conflicts ?? [],
|
|
806
|
+
held: heldBack,
|
|
807
|
+
overwritten: takenOver,
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
811
|
+
const parts = [`${res.written.length} written`, `${res.unchanged.length} unchanged`];
|
|
812
|
+
if (heldBack.length > 0) {
|
|
813
|
+
parts.push(`${heldBack.length} held (not ours to overwrite)`);
|
|
814
|
+
}
|
|
815
|
+
if (takenOver.length > 0) {
|
|
816
|
+
parts.push(`${takenOver.length} overwritten`);
|
|
817
|
+
}
|
|
818
|
+
if (twoWay && twoWay.imported > 0) {
|
|
819
|
+
parts.unshift(`${twoWay.imported} imported`);
|
|
820
|
+
}
|
|
821
|
+
if (res.orphaned && res.orphaned.length > 0) {
|
|
822
|
+
parts.push(`${res.orphaned.length} orphaned/absent from export (preserved safely)`);
|
|
823
|
+
}
|
|
824
|
+
if (res.deleted && res.deleted.length > 0) {
|
|
825
|
+
parts.push(`${res.deleted.length} deleted`);
|
|
826
|
+
}
|
|
827
|
+
if (twoWay && twoWay.conflicts.length > 0) {
|
|
828
|
+
console.error(
|
|
829
|
+
` ⚠ conflict(s), changed in BOTH the vault and the graph since the last sync — ` +
|
|
830
|
+
`held as you left them, not imported, not overwritten: ${twoWay.conflicts.join(", ")}`
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
if (takenOver.length > 0) {
|
|
834
|
+
console.error(
|
|
835
|
+
` ⚠ ${takenOver.length} file(s) you had edited were REPLACED with the graph's version ` +
|
|
836
|
+
`(--overwrite-unmanaged, or the settings panel): ${takenOver.join(", ")}`
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
if (heldBack.length > 0) {
|
|
840
|
+
console.error(
|
|
841
|
+
` ⚠ ${heldBack.length} file(s) were already here before this sync owned them and differ from the graph — ` +
|
|
842
|
+
`left exactly as you wrote them: ${heldBack.join(", ")}. ` +
|
|
843
|
+
`Pass --overwrite-unmanaged to replace them with the graph's version.`
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (res.written.length > 0 || res.deleted.length > 0 || heldBack.length > 0 || twoWayActivity) {
|
|
848
|
+
console.log(`[${timestamp}] Synced: ${parts.join(", ")}.`);
|
|
849
|
+
if (res.gitResult && res.gitResult.committed) {
|
|
850
|
+
console.log(` Git: ${res.gitResult.output}`);
|
|
851
|
+
} else if (res.gitResult && res.gitResult.changes) {
|
|
852
|
+
// The files are on disk, but the commit this run promised did not
|
|
853
|
+
// happen. Saying only "Synced" here would be a lie of omission.
|
|
854
|
+
console.error(` Git: NOT COMMITTED — ${redact(res.gitResult.output)}`);
|
|
855
|
+
}
|
|
856
|
+
} else if (!watchMode) {
|
|
857
|
+
console.log(`[${timestamp}] Graph is up-to-date (${parts.join(", ")}).`);
|
|
858
|
+
}
|
|
859
|
+
} catch (err) {
|
|
860
|
+
writeStatus({ ok: false, at: new Date().toISOString(), graph: graphName, error: redact(err.message) });
|
|
861
|
+
throw err;
|
|
862
|
+
} finally {
|
|
863
|
+
if (existsSync(tempEdnPath)) {
|
|
864
|
+
try { unlinkSync(tempEdnPath); } catch {}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
async function main() {
|
|
870
|
+
if (subcommand === "restore") return await restore();
|
|
871
|
+
|
|
872
|
+
// Print the resolved plan, not the flags that produced it — most of these
|
|
873
|
+
// were detected, and a wrong detection has to be visible at a glance.
|
|
874
|
+
// Never echo the token itself; these logs get pasted into bug reports.
|
|
875
|
+
console.log(`${PLUGIN_TITLE}: graph "${graphName}" ➔ ${vaultDir}`);
|
|
876
|
+
console.log(` geml ${GEML_DIR}/ — the source of truth; restore and --two-way read only this`);
|
|
877
|
+
console.log(
|
|
878
|
+
markdownDir === null
|
|
879
|
+
? " markdown off (--no-markdown)"
|
|
880
|
+
: markdownDir === vaultDir
|
|
881
|
+
? " markdown the vault root — open it in Logseq (file version); lossy and one-way"
|
|
882
|
+
: ` markdown ${markdownDir} — lossy and one-way`
|
|
883
|
+
);
|
|
884
|
+
if (appCli) {
|
|
885
|
+
console.log(` export via ${appCli.command} (${appCli.how}) — works with the graph open`);
|
|
886
|
+
} else if (apiServerToken) {
|
|
887
|
+
console.log(" export via @logseq/cli through the app's API server");
|
|
888
|
+
} else {
|
|
889
|
+
console.log(" export via @logseq/cli, opening the graph file directly — close the graph in Logseq first");
|
|
890
|
+
}
|
|
891
|
+
if (signalPath) console.log(` bridge ${signalPath}`);
|
|
892
|
+
if (gitCommit) {
|
|
893
|
+
console.log(" git auto-commit on, scoped to the vault");
|
|
894
|
+
} else if (flags.gitCommit !== false) {
|
|
895
|
+
console.log(` git off — ${vaultDir} is not a repository (\`git init\` there, or pass --git-commit)`);
|
|
896
|
+
}
|
|
897
|
+
if (flags.mirror) {
|
|
898
|
+
console.log(" mirror pages removed from the graph WILL be deleted here");
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (!watchMode) {
|
|
902
|
+
// One-shot mode: fail loudly with non-zero exit code if sync fails
|
|
903
|
+
try {
|
|
904
|
+
await performSync();
|
|
905
|
+
} catch (err) {
|
|
906
|
+
console.error(`[${new Date().toLocaleTimeString()}] Sync failed:`, redact(err.message));
|
|
907
|
+
process.exit(1);
|
|
908
|
+
}
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Watch mode: sequential non-overlapping syncs. The interval loop is the
|
|
913
|
+
// heartbeat; a --signal file, when given, triggers a sync the moment the
|
|
914
|
+
// in-app plugin reports a change, instead of waiting out the interval.
|
|
915
|
+
console.log(`Watch mode active (polling every ${flags.interval}s). Press Ctrl+C to stop.`);
|
|
916
|
+
|
|
917
|
+
let running = true;
|
|
918
|
+
let timer = null;
|
|
919
|
+
let isSyncing = false;
|
|
920
|
+
let queued = false;
|
|
921
|
+
let fsWatcher = null;
|
|
922
|
+
let signalTimer = null;
|
|
923
|
+
|
|
924
|
+
const cleanup = () => {
|
|
925
|
+
running = false;
|
|
926
|
+
if (timer) clearTimeout(timer);
|
|
927
|
+
if (signalTimer) clearTimeout(signalTimer);
|
|
928
|
+
if (fsWatcher) fsWatcher.close();
|
|
929
|
+
console.log("\nWatch mode stopped.");
|
|
930
|
+
process.exit(0);
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
process.on("SIGINT", cleanup);
|
|
934
|
+
process.on("SIGTERM", cleanup);
|
|
935
|
+
|
|
936
|
+
async function requestSync() {
|
|
937
|
+
if (!running) return;
|
|
938
|
+
if (isSyncing) {
|
|
939
|
+
// A change arrived mid-sync: run once more when this one finishes,
|
|
940
|
+
// rather than dropping it or overlapping exports.
|
|
941
|
+
queued = true;
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
isSyncing = true;
|
|
945
|
+
try {
|
|
946
|
+
await performSync();
|
|
947
|
+
} catch (err) {
|
|
948
|
+
console.error(`[${new Date().toLocaleTimeString()}] Sync error:`, redact(err.message));
|
|
949
|
+
} finally {
|
|
950
|
+
isSyncing = false;
|
|
951
|
+
}
|
|
952
|
+
if (queued) {
|
|
953
|
+
queued = false;
|
|
954
|
+
await requestSync();
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function scheduleNext() {
|
|
959
|
+
if (!running) return;
|
|
960
|
+
timer = setTimeout(async () => {
|
|
961
|
+
await requestSync();
|
|
962
|
+
scheduleNext();
|
|
963
|
+
}, flags.interval * 1000);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
if (signalPath) {
|
|
967
|
+
const signalDir = dirname(signalPath);
|
|
968
|
+
mkdirSync(signalDir, { recursive: true });
|
|
969
|
+
try {
|
|
970
|
+
// Watch the directory, not the file: the plugin's storage write may
|
|
971
|
+
// replace the file, and a watch pinned to the old inode goes silent.
|
|
972
|
+
fsWatcher = watch(signalDir, (eventType, filename) => {
|
|
973
|
+
// A null filename is legal on some platforms; treat it as a hit.
|
|
974
|
+
if (filename && filename !== basename(signalPath)) return;
|
|
975
|
+
if (signalTimer) clearTimeout(signalTimer);
|
|
976
|
+
signalTimer = setTimeout(() => {
|
|
977
|
+
signalTimer = null;
|
|
978
|
+
requestSync();
|
|
979
|
+
}, 300);
|
|
980
|
+
});
|
|
981
|
+
console.log(`Signal file watched: ${signalPath}`);
|
|
982
|
+
} catch (err) {
|
|
983
|
+
console.error(`Signal watch failed (${redact(err.message)}); interval polling only.`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
await requestSync();
|
|
988
|
+
scheduleNext();
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
main().catch((err) => {
|
|
992
|
+
console.error("Fatal:", err);
|
|
993
|
+
process.exit(1);
|
|
994
|
+
});
|