@geml/logseq-sync 2.0.5 â 2.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -4
- package/core/src/sync-engine.mjs +110 -16
- package/package.json +1 -1
- package/watcher/bin/logseq-sync.mjs +125 -15
package/README.md
CHANGED
|
@@ -20,7 +20,8 @@ Two settings, and only the first one usually needs touching:
|
|
|
20
20
|
- đĻ **A plain-text copy that stays yours** â every page a readable file, not a
|
|
21
21
|
database dump, in a folder you chose
|
|
22
22
|
- đ **Continuous, not one-shot** â edit in Logseq, and seconds later the file
|
|
23
|
-
on disk has caught up
|
|
23
|
+
on disk has caught up; with `--two-way`, edit the file and the graph
|
|
24
|
+
catches up the same way
|
|
24
25
|
- âŠī¸ **A way back** â `logseq-sync restore` imports the vault into a graph,
|
|
25
26
|
merging by block uuid. Files you can read are worth more when they are also
|
|
26
27
|
files you can return
|
|
@@ -150,6 +151,7 @@ found and what is missing, and exits non-zero when the setup cannot sync:
|
|
|
150
151
|
| `--once` | sync once and exit, instead of watching |
|
|
151
152
|
| `--git-commit` | commit, creating the vault repository if there is none |
|
|
152
153
|
| `--no-git-commit` | never touch git |
|
|
154
|
+
| `--two-way` | also import vault edits back, every cycle â conflicts held, deletions never imported (needs the app CLI) |
|
|
153
155
|
| `--mirror` | delete vault files for pages removed from the graph |
|
|
154
156
|
| `--markdown <dir>` | also write a lossy Markdown copy there, for other tools |
|
|
155
157
|
| `--interval <seconds>` | heartbeat between signals (default 10) |
|
|
@@ -199,11 +201,58 @@ reads them back from. *Debounce (seconds)* â quiet
|
|
|
199
201
|
period after the last change before the watcher is signalled (default 5; syncs
|
|
200
202
|
feed git commits, so this is deliberately calmer than UI-style debounce).
|
|
201
203
|
|
|
204
|
+
### Editing the vault from outside
|
|
205
|
+
|
|
206
|
+
The vault is ordinary text, and that is the point: agents, scripts and plain
|
|
207
|
+
`sed` all work on it, and none of them needs to know Logseq exists. With
|
|
208
|
+
`--two-way` running, an edit imports on the next cycle; without it, run
|
|
209
|
+
`logseq-sync restore` when you are ready.
|
|
210
|
+
|
|
211
|
+
**An agent (Claude, or anything speaking MCP)** gets addressed, validated
|
|
212
|
+
block edits from the [`geml` MCP server](https://github.com/geml-spec/geml):
|
|
213
|
+
|
|
214
|
+
```sh
|
|
215
|
+
npm i -g @geml/geml
|
|
216
|
+
geml mcp --root <your-vault-dir> --no-history
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`--no-history` matters here: git is this vault's history, and without the flag
|
|
220
|
+
every MCP write also saves a `.gemlhistory` sidecar revision beside the file.
|
|
221
|
+
(If you want those too, drop the flag â the sync ignores sidecars either way
|
|
222
|
+
and never commits them.)
|
|
223
|
+
|
|
224
|
+
**A one-liner** reads or edits one block by its address â every block carries
|
|
225
|
+
its uuid:
|
|
226
|
+
|
|
227
|
+
```sh
|
|
228
|
+
geml find "that phrase" <vault-dir> # â pages/foo.geml #<uuid>
|
|
229
|
+
geml get <vault-dir>/pages/foo.geml '#<uuid>'
|
|
230
|
+
printf 'new text' | geml set <vault-dir>/pages/foo.geml '#<uuid>' --in - -o <same-file>
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Bulk refactoring** is whatever your shell already does â the result is
|
|
234
|
+
re-imported by uuid, so identity survives the edit:
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
grep -rl "old-tag" <vault-dir>/pages | xargs sed -i 's/old-tag/new-tag/g'
|
|
238
|
+
logseq-sync restore <vault-dir> --yes # or let --two-way pick it up
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
A `geml check <file>` after a bulk edit is cheap insurance: it names a mangled
|
|
242
|
+
block before the import carries it into the graph, while the diff is still in
|
|
243
|
+
front of you.
|
|
244
|
+
|
|
202
245
|
## Honesty corner
|
|
203
246
|
|
|
204
|
-
- The **
|
|
205
|
-
command (`restore`)
|
|
206
|
-
|
|
247
|
+
- The **default** continuous direction is graph â files; going back is a
|
|
248
|
+
deliberate command (`restore`). `--two-way` makes the return trip continuous
|
|
249
|
+
too â every cycle imports what changed in the vault â under three rules that
|
|
250
|
+
say what it does NOT pretend to solve: a file changed on **both** sides
|
|
251
|
+
since the last sync is a conflict, held exactly as you left it (not
|
|
252
|
+
imported, not overwritten, named in the toolbar status until you merge it);
|
|
253
|
+
deletions are **never** imported; and a graph backup is taken before the
|
|
254
|
+
first import and every tenth after. The sync tells its own writes from
|
|
255
|
+
yours by content hash, so nothing echoes.
|
|
207
256
|
- Files the sync did not write are never touched: a manifest tracks what it
|
|
208
257
|
owns, and `--mirror` only ever removes files from that list.
|
|
209
258
|
- **The app's lock is the thing to know about.** A running Logseq holds
|
package/core/src/sync-engine.mjs
CHANGED
|
@@ -14,11 +14,83 @@ import {
|
|
|
14
14
|
} from "node:fs";
|
|
15
15
|
import { join, dirname, relative, resolve, sep, isAbsolute } from "node:path";
|
|
16
16
|
import { execFileSync } from "node:child_process";
|
|
17
|
-
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
18
18
|
import { ednToGemlFiles, gemlFilesToEdn } from "./mapping.mjs";
|
|
19
19
|
|
|
20
20
|
const MANIFEST_FILE = ".geml-manifest.json";
|
|
21
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
|
+
|
|
22
94
|
/**
|
|
23
95
|
* Normalize line endings to LF, handling CRLF (\r\n) and lone CR (\r).
|
|
24
96
|
*/
|
|
@@ -100,27 +172,27 @@ export function readGemlFilesFromDisk(dir, baseDir = dir) {
|
|
|
100
172
|
* @param {string} targetDir Local destination directory.
|
|
101
173
|
* @param {object} [opts]
|
|
102
174
|
* @param {boolean} [opts.deleteOrphans=false] Whether to delete previous-sync .geml files no longer in graph.
|
|
103
|
-
* @
|
|
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[] }}
|
|
104
179
|
*/
|
|
105
180
|
export function writeGemlFilesToDisk(gemlFiles, targetDir, opts = {}) {
|
|
106
181
|
const deleteOrphans = opts.deleteOrphans ?? false;
|
|
182
|
+
const preserve = new Set(opts.preserve ?? []);
|
|
107
183
|
const written = [];
|
|
108
184
|
const unchanged = [];
|
|
109
185
|
const orphaned = [];
|
|
110
186
|
const deleted = [];
|
|
187
|
+
const preserved = [];
|
|
111
188
|
|
|
112
189
|
mkdirSync(targetDir, { recursive: true });
|
|
113
190
|
const existingFiles = readGemlFilesFromDisk(targetDir);
|
|
114
191
|
|
|
115
192
|
// Load previous sync manifest to know which files belong to sync vs user-authored files
|
|
116
193
|
const manifestPath = join(targetDir, MANIFEST_FILE);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
121
|
-
if (Array.isArray(parsed)) lastManifest = new Set(parsed);
|
|
122
|
-
} catch {}
|
|
123
|
-
}
|
|
194
|
+
const previous = readManifest(targetDir);
|
|
195
|
+
const lastManifest = new Set(previous.files.keys());
|
|
124
196
|
|
|
125
197
|
// Write new or updated files atomically with CRLF normalization
|
|
126
198
|
for (const [rel, newContent] of gemlFiles) {
|
|
@@ -128,7 +200,10 @@ export function writeGemlFilesToDisk(gemlFiles, targetDir, opts = {}) {
|
|
|
128
200
|
const normNew = normalizeEol(newContent);
|
|
129
201
|
const existingContent = existingFiles.get(rel);
|
|
130
202
|
|
|
131
|
-
if (
|
|
203
|
+
if (preserve.has(rel)) {
|
|
204
|
+
if (existingContent !== normNew) preserved.push(rel);
|
|
205
|
+
else unchanged.push(rel);
|
|
206
|
+
} else if (existingContent === undefined || existingContent !== normNew) {
|
|
132
207
|
atomicWriteFileSync(fullPath, normNew);
|
|
133
208
|
written.push(rel);
|
|
134
209
|
} else {
|
|
@@ -153,17 +228,32 @@ export function writeGemlFilesToDisk(gemlFiles, targetDir, opts = {}) {
|
|
|
153
228
|
}
|
|
154
229
|
}
|
|
155
230
|
|
|
156
|
-
// Save updated manifest of managed sync files:
|
|
157
|
-
//
|
|
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.
|
|
158
236
|
const currentManifest = new Set(gemlFiles.keys());
|
|
159
237
|
for (const rel of lastManifest) {
|
|
160
238
|
if (existingFiles.has(rel) && !deleted.includes(rel)) {
|
|
161
239
|
currentManifest.add(rel);
|
|
162
240
|
}
|
|
163
241
|
}
|
|
164
|
-
|
|
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");
|
|
165
255
|
|
|
166
|
-
return { written, orphaned, unchanged, deleted };
|
|
256
|
+
return { written, orphaned, unchanged, deleted, preserved };
|
|
167
257
|
}
|
|
168
258
|
|
|
169
259
|
/**
|
|
@@ -344,12 +434,16 @@ export async function syncEdnToDisk(ednText, targetDir, opts = {}) {
|
|
|
344
434
|
|
|
345
435
|
/**
|
|
346
436
|
* Full Sync Pipeline from disk back to EDN string.
|
|
347
|
-
*
|
|
437
|
+
*
|
|
348
438
|
* @param {string} targetDir Local folder containing .geml files.
|
|
349
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).
|
|
350
443
|
* @returns {string} EDN string ready for logseq import-edn.
|
|
351
444
|
*/
|
|
352
|
-
export function syncDiskToEdn(targetDir, lib) {
|
|
445
|
+
export function syncDiskToEdn(targetDir, lib, opts = {}) {
|
|
353
446
|
const files = readGemlFilesFromDisk(targetDir);
|
|
447
|
+
for (const rel of opts.exclude ?? []) files.delete(rel);
|
|
354
448
|
return gemlFilesToEdn(files, lib);
|
|
355
449
|
}
|
package/package.json
CHANGED
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
import { join, resolve, dirname, basename, sep } from "node:path";
|
|
10
10
|
import { tmpdir, homedir } from "node:os";
|
|
11
11
|
import { randomUUID, createHash } from "node:crypto";
|
|
12
|
-
import { syncEdnToDisk, syncDiskToEdn, atomicWriteFileSync } from "../../core/src/sync-engine.mjs";
|
|
12
|
+
import { syncEdnToDisk, syncDiskToEdn, atomicWriteFileSync, detectExternalEdits } from "../../core/src/sync-engine.mjs";
|
|
13
|
+
import { ednToGemlFiles } from "../../core/src/mapping.mjs";
|
|
13
14
|
import { STATUS_FILE } from "../../core/src/bridge.mjs";
|
|
14
15
|
import { parse as parseGeml, addressedUnits, sliceUnit, gemlToMd } from "@geml/geml";
|
|
15
16
|
|
|
@@ -39,6 +40,12 @@ to override it.
|
|
|
39
40
|
|
|
40
41
|
Flags:
|
|
41
42
|
--once Sync once and exit (default: keep watching)
|
|
43
|
+
--two-way Also import vault edits back into the graph, checked
|
|
44
|
+
on every cycle. A file changed on BOTH sides is a
|
|
45
|
+
conflict: neither imported nor overwritten, reported
|
|
46
|
+
until you merge it. Deletions are never imported.
|
|
47
|
+
Takes a graph backup before the first import and
|
|
48
|
+
every 10th after. Needs the app CLI.
|
|
42
49
|
--git-commit Commit, creating the vault repository if there is none
|
|
43
50
|
(default: commit only when the vault ALREADY is a repository)
|
|
44
51
|
--no-git-commit Never touch git
|
|
@@ -64,6 +71,7 @@ const args = process.argv.slice(2);
|
|
|
64
71
|
const positional = [];
|
|
65
72
|
const flags = {
|
|
66
73
|
once: false,
|
|
74
|
+
twoWay: false,
|
|
67
75
|
gitCommit: "auto",
|
|
68
76
|
mirror: false,
|
|
69
77
|
markdown: null,
|
|
@@ -102,6 +110,8 @@ for (let i = 0; i < args.length; i++) {
|
|
|
102
110
|
flags.yes = true;
|
|
103
111
|
} else if (arg === "--no-backup") {
|
|
104
112
|
flags.backup = false;
|
|
113
|
+
} else if (arg === "--two-way") {
|
|
114
|
+
flags.twoWay = true;
|
|
105
115
|
} else if (arg === "--mirror") {
|
|
106
116
|
flags.mirror = true;
|
|
107
117
|
} else if (arg === "--markdown") {
|
|
@@ -436,6 +446,14 @@ const signalPath = resolveSignalPath();
|
|
|
436
446
|
const watchMode = !flags.once;
|
|
437
447
|
const gitCommit = subcommand === "restore" ? false : resolveGitCommit();
|
|
438
448
|
|
|
449
|
+
if (flags.twoWay && !appCli) {
|
|
450
|
+
console.error(
|
|
451
|
+
"Error: --two-way needs the Logseq desktop app's CLI (it performs the imports). " +
|
|
452
|
+
"Install Logseq, or pass --app-cli <path>. Run `logseq-sync doctor` for the full picture."
|
|
453
|
+
);
|
|
454
|
+
process.exit(2);
|
|
455
|
+
}
|
|
456
|
+
|
|
439
457
|
function isGitRepo(dir) {
|
|
440
458
|
try {
|
|
441
459
|
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
@@ -599,21 +617,86 @@ async function restore() {
|
|
|
599
617
|
|
|
600
618
|
let lastEdnHash = null;
|
|
601
619
|
|
|
620
|
+
// â¤'s bookkeeping: a graph backup before the session's first import, then
|
|
621
|
+
// every BACKUP_EVERY imports after â enough that an import gone wrong always
|
|
622
|
+
// has a recent restore point, without one backup per keystroke.
|
|
623
|
+
let sessionBackupTaken = false;
|
|
624
|
+
let importsSinceBackup = 0;
|
|
625
|
+
const BACKUP_EVERY = 10;
|
|
626
|
+
|
|
627
|
+
// Export the graph as EDN into tempPath â the one exporter, used once per
|
|
628
|
+
// cycle, twice when a two-way import changed the graph mid-cycle.
|
|
629
|
+
// With a token the CLI goes through the running app's HTTP API server and
|
|
630
|
+
// exports whatever graph the app has OPEN â the graph name is not part of
|
|
631
|
+
// that request, so -a REPLACES -g rather than joining it. Without a token
|
|
632
|
+
// the CLI opens the named graph's sqlite directly, which only works while
|
|
633
|
+
// the app does not hold the lock on it.
|
|
634
|
+
function exportGraphEdn(tempPath) {
|
|
635
|
+
if (appCli) {
|
|
636
|
+
runAppCli(tempPath);
|
|
637
|
+
} else {
|
|
638
|
+
const exportSource = apiServerToken ? ["-a", apiServerToken] : ["-g", graphName];
|
|
639
|
+
runLogseqCli("export-edn", ...exportSource, "-f", tempPath);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// The import half of --two-way, run before the export lands on disk: whatever
|
|
644
|
+
// a person or agent changed in the vault goes back into the graph first, so
|
|
645
|
+
// the write that follows holds the merged state and re-baselines the
|
|
646
|
+
// manifest. Deletions are reported, never imported (the vault's stance, now
|
|
647
|
+
// in both directions); a file changed on BOTH sides since the last sync is a
|
|
648
|
+
// conflict â importing it would clobber the graph's edit, exporting over it
|
|
649
|
+
// would clobber the person's, so two-way does neither and says so until a
|
|
650
|
+
// person merges.
|
|
651
|
+
async function importExternalEdits(ednText) {
|
|
652
|
+
const graphFiles = ednToGemlFiles(ednText);
|
|
653
|
+
const edits = detectExternalEdits(targetDir, { graphFiles });
|
|
654
|
+
if (!edits.baselineKnown) {
|
|
655
|
+
// A v1 manifest (or none) has no content baseline â the sync about to run
|
|
656
|
+
// writes one, and the NEXT cycle can start importing.
|
|
657
|
+
return { imported: 0, conflicts: [], missing: [] };
|
|
658
|
+
}
|
|
659
|
+
const importable = [...edits.modified, ...edits.added];
|
|
660
|
+
const result = { imported: 0, conflicts: edits.conflicts, missing: edits.missing };
|
|
661
|
+
if (edits.missing.length > 0) {
|
|
662
|
+
console.log(
|
|
663
|
+
` two-way: ${edits.missing.length} vault file(s) deleted on disk â deletions are never imported; ` +
|
|
664
|
+
`delete the page in Logseq if you mean it.`
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
if (importable.length === 0) return result;
|
|
668
|
+
|
|
669
|
+
if (!sessionBackupTaken || importsSinceBackup >= BACKUP_EVERY) {
|
|
670
|
+
appCliRun("graph", "backup", "create", "--graph", graphName);
|
|
671
|
+
sessionBackupTaken = true;
|
|
672
|
+
importsSinceBackup = 0;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const tmpEdn = join(tmpdir(), `geml-twoway-${process.pid}-${randomUUID()}.edn`);
|
|
676
|
+
try {
|
|
677
|
+
atomicWriteFileSync(
|
|
678
|
+
tmpEdn,
|
|
679
|
+
syncDiskToEdn(targetDir, { parse: parseGeml, addressedUnits, sliceUnit }, { exclude: edits.conflicts })
|
|
680
|
+
);
|
|
681
|
+
appCliRun("graph", "import", "--graph", graphName, "--type", "edn", "--input", tmpEdn);
|
|
682
|
+
} finally {
|
|
683
|
+
if (existsSync(tmpEdn)) { try { unlinkSync(tmpEdn); } catch {} }
|
|
684
|
+
}
|
|
685
|
+
importsSinceBackup += 1;
|
|
686
|
+
result.imported = importable.length;
|
|
687
|
+
console.log(
|
|
688
|
+
`[${new Date().toLocaleTimeString()}] two-way: imported ${importable.length} vault edit(s) into "${graphName}"` +
|
|
689
|
+
(edits.conflicts.length ? `; ${edits.conflicts.length} conflict(s) held` : "") +
|
|
690
|
+
`.`
|
|
691
|
+
);
|
|
692
|
+
return result;
|
|
693
|
+
}
|
|
694
|
+
|
|
602
695
|
async function performSync() {
|
|
603
696
|
const tempEdnPath = join(tmpdir(), `logseq-export-${process.pid}-${Date.now()}-${randomUUID()}.edn`);
|
|
604
697
|
try {
|
|
605
698
|
// 1. Export from Logseq DB via official CLI.
|
|
606
|
-
|
|
607
|
-
// exports whatever graph the app has OPEN â the graph name is not part of
|
|
608
|
-
// that request, so -a REPLACES -g rather than joining it. Without a token
|
|
609
|
-
// the CLI opens the named graph's sqlite directly, which only works while
|
|
610
|
-
// the app does not hold the lock on it.
|
|
611
|
-
if (appCli) {
|
|
612
|
-
runAppCli(tempEdnPath);
|
|
613
|
-
} else {
|
|
614
|
-
const exportSource = apiServerToken ? ["-a", apiServerToken] : ["-g", graphName];
|
|
615
|
-
runLogseqCli("export-edn", ...exportSource, "-f", tempEdnPath);
|
|
616
|
-
}
|
|
699
|
+
exportGraphEdn(tempEdnPath);
|
|
617
700
|
|
|
618
701
|
if (!existsSync(tempEdnPath)) {
|
|
619
702
|
throw new Error(`Export failed: ${tempEdnPath} was not created.`);
|
|
@@ -624,11 +707,26 @@ async function performSync() {
|
|
|
624
707
|
throw new Error(`Export produced an empty (0 byte) EDN file.`);
|
|
625
708
|
}
|
|
626
709
|
|
|
627
|
-
|
|
710
|
+
let ednText = readFileSync(tempEdnPath, "utf8");
|
|
711
|
+
|
|
712
|
+
// 1.5 Two-way import, BEFORE the unchanged-export short-circuit below:
|
|
713
|
+
// the graph being unchanged says nothing about the vault.
|
|
714
|
+
let twoWay = null;
|
|
715
|
+
if (flags.twoWay) {
|
|
716
|
+
twoWay = await importExternalEdits(ednText);
|
|
717
|
+
if (twoWay.imported > 0) {
|
|
718
|
+
// The graph just absorbed the vault edits â export again, so the disk
|
|
719
|
+
// write and the manifest baseline hold the merged state.
|
|
720
|
+
exportGraphEdn(tempEdnPath);
|
|
721
|
+
ednText = readFileSync(tempEdnPath, "utf8");
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
const twoWayActivity =
|
|
725
|
+
twoWay !== null && (twoWay.imported > 0 || twoWay.conflicts.length > 0 || twoWay.missing.length > 0);
|
|
628
726
|
|
|
629
727
|
// 2. Efficiency: In watch mode, skip disk scanning if export content is bit-for-bit identical
|
|
630
728
|
const currentHash = createHash("sha256").update(ednText).digest("hex");
|
|
631
|
-
if (watchMode && currentHash === lastEdnHash) {
|
|
729
|
+
if (watchMode && currentHash === lastEdnHash && !twoWayActivity) {
|
|
632
730
|
return;
|
|
633
731
|
}
|
|
634
732
|
|
|
@@ -636,6 +734,7 @@ async function performSync() {
|
|
|
636
734
|
const res = await syncEdnToDisk(ednText, targetDir, {
|
|
637
735
|
autoCommit: gitCommit,
|
|
638
736
|
deleteOrphans: flags.mirror,
|
|
737
|
+
preserve: twoWay?.conflicts ?? [],
|
|
639
738
|
markdownDir: flags.markdown ? resolve(expandHome(flags.markdown)) : null,
|
|
640
739
|
gemlToMd: gemlSourceToMd,
|
|
641
740
|
commitMessage: flags.message || `logseq-geml: sync graph "${graphName}" (${new Date().toISOString()})`,
|
|
@@ -650,18 +749,29 @@ async function performSync() {
|
|
|
650
749
|
unchanged: res.unchanged.length,
|
|
651
750
|
orphaned: res.orphaned.length,
|
|
652
751
|
deleted: res.deleted.length,
|
|
752
|
+
imported: twoWay?.imported ?? 0,
|
|
753
|
+
conflicts: twoWay?.conflicts ?? [],
|
|
653
754
|
});
|
|
654
755
|
|
|
655
756
|
const timestamp = new Date().toLocaleTimeString();
|
|
656
757
|
const parts = [`${res.written.length} written`, `${res.unchanged.length} unchanged`];
|
|
758
|
+
if (twoWay && twoWay.imported > 0) {
|
|
759
|
+
parts.unshift(`${twoWay.imported} imported`);
|
|
760
|
+
}
|
|
657
761
|
if (res.orphaned && res.orphaned.length > 0) {
|
|
658
762
|
parts.push(`${res.orphaned.length} orphaned/absent from export (preserved safely)`);
|
|
659
763
|
}
|
|
660
764
|
if (res.deleted && res.deleted.length > 0) {
|
|
661
765
|
parts.push(`${res.deleted.length} deleted`);
|
|
662
766
|
}
|
|
767
|
+
if (twoWay && twoWay.conflicts.length > 0) {
|
|
768
|
+
console.error(
|
|
769
|
+
` â conflict(s), changed in BOTH the vault and the graph since the last sync â ` +
|
|
770
|
+
`held as you left them, not imported, not overwritten: ${twoWay.conflicts.join(", ")}`
|
|
771
|
+
);
|
|
772
|
+
}
|
|
663
773
|
|
|
664
|
-
if (res.written.length > 0 || res.deleted.length > 0) {
|
|
774
|
+
if (res.written.length > 0 || res.deleted.length > 0 || twoWayActivity) {
|
|
665
775
|
console.log(`[${timestamp}] Synced: ${parts.join(", ")}.`);
|
|
666
776
|
if (res.gitResult && res.gitResult.committed) {
|
|
667
777
|
console.log(` Git: ${res.gitResult.output}`);
|