@geml/logseq-sync 2.0.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 +28 -0
- package/README.md +212 -0
- package/core/src/bridge.mjs +8 -0
- package/core/src/mapping.mjs +243 -0
- package/core/src/sync-engine.mjs +324 -0
- package/docs/how-it-works.svg +42 -0
- package/package.json +53 -0
- package/watcher/bin/create-graph.mjs +51 -0
- package/watcher/bin/create_graph_headless.cljs +22 -0
- package/watcher/bin/geml-sync.mjs +290 -0
- package/watcher/bin/live-roundtrip.mjs +131 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI Companion for Logseq GEML Sync
|
|
3
|
+
// Runs continuous or one-shot sync from a Logseq DB graph to a local Git-versioned GEML folder.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// node watcher/bin/geml-sync.mjs <graph-name> <target-dir> [flags]
|
|
7
|
+
//
|
|
8
|
+
// Flags:
|
|
9
|
+
// --watch Run in continuous watch/sync loop
|
|
10
|
+
// --interval <seconds> Poll interval for watch mode (positive integer, default: 10)
|
|
11
|
+
// --git-commit Auto-commit changes to git (scoped strictly to sync folder)
|
|
12
|
+
// --message <text> Custom git commit message template
|
|
13
|
+
// --signal <file> Sync immediately when this file changes (the in-app
|
|
14
|
+
// plugin writes it via logseq.FileStorage), and write
|
|
15
|
+
// the sync result to geml-sync-status.json beside it
|
|
16
|
+
// so the plugin can show it. Interval stays as fallback.
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { readFileSync, unlinkSync, existsSync, statSync, mkdirSync, watch } from "node:fs";
|
|
20
|
+
import { join, resolve, dirname, basename } from "node:path";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
23
|
+
import { syncEdnToDisk, atomicWriteFileSync } from "../../core/src/sync-engine.mjs";
|
|
24
|
+
import { STATUS_FILE } from "../../core/src/bridge.mjs";
|
|
25
|
+
|
|
26
|
+
const args = process.argv.slice(2);
|
|
27
|
+
const positional = [];
|
|
28
|
+
const flags = {
|
|
29
|
+
watch: false,
|
|
30
|
+
gitCommit: false,
|
|
31
|
+
interval: 10,
|
|
32
|
+
message: null,
|
|
33
|
+
signal: null,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < args.length; i++) {
|
|
37
|
+
const arg = args[i];
|
|
38
|
+
if (arg === "--watch") {
|
|
39
|
+
flags.watch = true;
|
|
40
|
+
} else if (arg === "--git-commit") {
|
|
41
|
+
flags.gitCommit = true;
|
|
42
|
+
} else if (arg === "--interval") {
|
|
43
|
+
if (i + 1 >= args.length) {
|
|
44
|
+
console.error("Error: --interval requires a value.");
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
const rawVal = args[++i];
|
|
48
|
+
const val = Number(rawVal);
|
|
49
|
+
if (!Number.isInteger(val) || val <= 0) {
|
|
50
|
+
console.error(`Error: --interval must be a positive integer >= 1 (got "${rawVal}").`);
|
|
51
|
+
process.exit(2);
|
|
52
|
+
}
|
|
53
|
+
flags.interval = val;
|
|
54
|
+
} else if (arg === "--message") {
|
|
55
|
+
if (i + 1 >= args.length) {
|
|
56
|
+
console.error("Error: --message requires a value.");
|
|
57
|
+
process.exit(2);
|
|
58
|
+
}
|
|
59
|
+
flags.message = args[++i];
|
|
60
|
+
} else if (arg === "--signal") {
|
|
61
|
+
if (i + 1 >= args.length) {
|
|
62
|
+
console.error("Error: --signal requires a value.");
|
|
63
|
+
process.exit(2);
|
|
64
|
+
}
|
|
65
|
+
flags.signal = args[++i];
|
|
66
|
+
} else if (arg.startsWith("--")) {
|
|
67
|
+
console.error(`Error: Unknown flag "${arg}".`);
|
|
68
|
+
process.exit(2);
|
|
69
|
+
} else {
|
|
70
|
+
positional.push(arg);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (positional.length < 2) {
|
|
75
|
+
console.error("Usage: node watcher/bin/geml-sync.mjs <graph-name> <target-dir> [--watch] [--interval <sec>] [--git-commit] [--message <text>] [--signal <file>]");
|
|
76
|
+
process.exit(2);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const [graphName, targetDirRaw] = positional;
|
|
80
|
+
|
|
81
|
+
// Validate graph name to prevent command/path injection
|
|
82
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(graphName)) {
|
|
83
|
+
console.error(`Error: Invalid graph name "${graphName}". Only alphanumeric characters, hyphens, and underscores are allowed.`);
|
|
84
|
+
process.exit(2);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const targetDir = resolve(targetDirRaw);
|
|
88
|
+
const cliCwd = process.env.LOGSEQ_CLI_DIR ?? process.cwd();
|
|
89
|
+
const signalPath = flags.signal ? resolve(flags.signal) : null;
|
|
90
|
+
|
|
91
|
+
// The status file lands beside the signal file — the plugin's storage
|
|
92
|
+
// directory — the one place logseq.FileStorage.getItem can read it back from.
|
|
93
|
+
function writeStatus(status) {
|
|
94
|
+
if (!signalPath) return;
|
|
95
|
+
try {
|
|
96
|
+
atomicWriteFileSync(join(dirname(signalPath), STATUS_FILE), JSON.stringify(status, null, 1) + "\n");
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error(`Could not write status file: ${err.message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Find @logseq/cli entry point or run via npx without shell: true
|
|
103
|
+
function runLogseqCli(...cmdArgs) {
|
|
104
|
+
const directCliPath = resolve(cliCwd, "node_modules", "@logseq", "cli", "cli.mjs");
|
|
105
|
+
if (existsSync(directCliPath)) {
|
|
106
|
+
return execFileSync(process.execPath, [directCliPath, ...cmdArgs], {
|
|
107
|
+
cwd: cliCwd,
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
shell: false,
|
|
110
|
+
maxBuffer: 1 << 28,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Fallback to npx (executable npx.cmd on Windows, npx on Unix) without shell: true
|
|
115
|
+
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
116
|
+
return execFileSync(npxCmd, ["-y", "@logseq/cli", ...cmdArgs], {
|
|
117
|
+
cwd: cliCwd,
|
|
118
|
+
encoding: "utf8",
|
|
119
|
+
shell: false,
|
|
120
|
+
maxBuffer: 1 << 28,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let lastEdnHash = null;
|
|
125
|
+
|
|
126
|
+
async function performSync() {
|
|
127
|
+
const tempEdnPath = join(tmpdir(), `logseq-export-${process.pid}-${Date.now()}-${randomUUID()}.edn`);
|
|
128
|
+
try {
|
|
129
|
+
// 1. Export from Logseq DB via official CLI
|
|
130
|
+
runLogseqCli("export-edn", "-g", graphName, "-f", tempEdnPath);
|
|
131
|
+
|
|
132
|
+
if (!existsSync(tempEdnPath)) {
|
|
133
|
+
throw new Error(`Export failed: ${tempEdnPath} was not created.`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const stat = statSync(tempEdnPath);
|
|
137
|
+
if (stat.size === 0) {
|
|
138
|
+
throw new Error(`Export produced an empty (0 byte) EDN file.`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ednText = readFileSync(tempEdnPath, "utf8");
|
|
142
|
+
|
|
143
|
+
// 2. Efficiency: In watch mode, skip disk scanning if export content is bit-for-bit identical
|
|
144
|
+
const currentHash = createHash("sha256").update(ednText).digest("hex");
|
|
145
|
+
if (flags.watch && currentHash === lastEdnHash) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 3. Incremental sync to disk
|
|
150
|
+
const res = await syncEdnToDisk(ednText, targetDir, {
|
|
151
|
+
autoCommit: flags.gitCommit,
|
|
152
|
+
commitMessage: flags.message || `logseq-geml: sync graph "${graphName}" (${new Date().toISOString()})`,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
lastEdnHash = currentHash;
|
|
156
|
+
writeStatus({
|
|
157
|
+
ok: true,
|
|
158
|
+
at: new Date().toISOString(),
|
|
159
|
+
graph: graphName,
|
|
160
|
+
written: res.written.length,
|
|
161
|
+
unchanged: res.unchanged.length,
|
|
162
|
+
orphaned: res.orphaned.length,
|
|
163
|
+
deleted: res.deleted.length,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
167
|
+
const parts = [`${res.written.length} written`, `${res.unchanged.length} unchanged`];
|
|
168
|
+
if (res.orphaned && res.orphaned.length > 0) {
|
|
169
|
+
parts.push(`${res.orphaned.length} orphaned/absent from export (preserved safely)`);
|
|
170
|
+
}
|
|
171
|
+
if (res.deleted && res.deleted.length > 0) {
|
|
172
|
+
parts.push(`${res.deleted.length} deleted`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (res.written.length > 0 || res.deleted.length > 0) {
|
|
176
|
+
console.log(`[${timestamp}] Synced: ${parts.join(", ")}.`);
|
|
177
|
+
if (res.gitResult && res.gitResult.committed) {
|
|
178
|
+
console.log(` Git: ${res.gitResult.output}`);
|
|
179
|
+
}
|
|
180
|
+
} else if (!flags.watch) {
|
|
181
|
+
console.log(`[${timestamp}] Graph is up-to-date (${parts.join(", ")}).`);
|
|
182
|
+
}
|
|
183
|
+
} catch (err) {
|
|
184
|
+
writeStatus({ ok: false, at: new Date().toISOString(), graph: graphName, error: err.message });
|
|
185
|
+
throw err;
|
|
186
|
+
} finally {
|
|
187
|
+
if (existsSync(tempEdnPath)) {
|
|
188
|
+
try { unlinkSync(tempEdnPath); } catch {}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function main() {
|
|
194
|
+
console.log(`Starting GEML Sync: Graph "${graphName}" ➔ ${targetDir}`);
|
|
195
|
+
if (flags.gitCommit) console.log("Git auto-commit: enabled (scoped to target paths)");
|
|
196
|
+
|
|
197
|
+
if (!flags.watch) {
|
|
198
|
+
// One-shot mode: fail loudly with non-zero exit code if sync fails
|
|
199
|
+
try {
|
|
200
|
+
await performSync();
|
|
201
|
+
} catch (err) {
|
|
202
|
+
console.error(`[${new Date().toLocaleTimeString()}] Sync failed:`, err.message);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Watch mode: sequential non-overlapping syncs. The interval loop is the
|
|
209
|
+
// heartbeat; a --signal file, when given, triggers a sync the moment the
|
|
210
|
+
// in-app plugin reports a change, instead of waiting out the interval.
|
|
211
|
+
console.log(`Watch mode active (polling every ${flags.interval}s). Press Ctrl+C to stop.`);
|
|
212
|
+
|
|
213
|
+
let running = true;
|
|
214
|
+
let timer = null;
|
|
215
|
+
let isSyncing = false;
|
|
216
|
+
let queued = false;
|
|
217
|
+
let fsWatcher = null;
|
|
218
|
+
let signalTimer = null;
|
|
219
|
+
|
|
220
|
+
const cleanup = () => {
|
|
221
|
+
running = false;
|
|
222
|
+
if (timer) clearTimeout(timer);
|
|
223
|
+
if (signalTimer) clearTimeout(signalTimer);
|
|
224
|
+
if (fsWatcher) fsWatcher.close();
|
|
225
|
+
console.log("\nWatch mode stopped.");
|
|
226
|
+
process.exit(0);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
process.on("SIGINT", cleanup);
|
|
230
|
+
process.on("SIGTERM", cleanup);
|
|
231
|
+
|
|
232
|
+
async function requestSync() {
|
|
233
|
+
if (!running) return;
|
|
234
|
+
if (isSyncing) {
|
|
235
|
+
// A change arrived mid-sync: run once more when this one finishes,
|
|
236
|
+
// rather than dropping it or overlapping exports.
|
|
237
|
+
queued = true;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
isSyncing = true;
|
|
241
|
+
try {
|
|
242
|
+
await performSync();
|
|
243
|
+
} catch (err) {
|
|
244
|
+
console.error(`[${new Date().toLocaleTimeString()}] Sync error:`, err.message);
|
|
245
|
+
} finally {
|
|
246
|
+
isSyncing = false;
|
|
247
|
+
}
|
|
248
|
+
if (queued) {
|
|
249
|
+
queued = false;
|
|
250
|
+
await requestSync();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function scheduleNext() {
|
|
255
|
+
if (!running) return;
|
|
256
|
+
timer = setTimeout(async () => {
|
|
257
|
+
await requestSync();
|
|
258
|
+
scheduleNext();
|
|
259
|
+
}, flags.interval * 1000);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (signalPath) {
|
|
263
|
+
const signalDir = dirname(signalPath);
|
|
264
|
+
mkdirSync(signalDir, { recursive: true });
|
|
265
|
+
try {
|
|
266
|
+
// Watch the directory, not the file: the plugin's storage write may
|
|
267
|
+
// replace the file, and a watch pinned to the old inode goes silent.
|
|
268
|
+
fsWatcher = watch(signalDir, (eventType, filename) => {
|
|
269
|
+
// A null filename is legal on some platforms; treat it as a hit.
|
|
270
|
+
if (filename && filename !== basename(signalPath)) return;
|
|
271
|
+
if (signalTimer) clearTimeout(signalTimer);
|
|
272
|
+
signalTimer = setTimeout(() => {
|
|
273
|
+
signalTimer = null;
|
|
274
|
+
requestSync();
|
|
275
|
+
}, 300);
|
|
276
|
+
});
|
|
277
|
+
console.log(`Signal file watched: ${signalPath}`);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
console.error(`Signal watch failed (${err.message}); interval polling only.`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await requestSync();
|
|
284
|
+
scheduleNext();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
main().catch((err) => {
|
|
288
|
+
console.error("Fatal:", err);
|
|
289
|
+
process.exit(1);
|
|
290
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The live half of the spike: run the round trip against a REAL DB graph via
|
|
2
|
+
// the official @logseq/cli, with Logseq's own `validate` as the judge.
|
|
3
|
+
//
|
|
4
|
+
// node bin/live-roundtrip.mjs <graph-name> [--edit]
|
|
5
|
+
//
|
|
6
|
+
// Stages (each printed, each gated):
|
|
7
|
+
// 1. `logseq export-edn -g <graph>` → out/export-1.edn
|
|
8
|
+
// 2. ednToGemlFiles → out/geml/**.geml
|
|
9
|
+
// 3. `geml check --root out/geml` on every doc (zero errors required)
|
|
10
|
+
// 4. gemlFilesToEdn → out/import.edn
|
|
11
|
+
// 5. STRUCTURAL identity export-1 ⇄ import.edn (EDN semantics) — the offline
|
|
12
|
+
// criterion, now on real data
|
|
13
|
+
// 6. with --edit: `geml set` the first uuid block, rebuild import.edn, and
|
|
14
|
+
// `logseq import-edn` it back, then `logseq validate` — the semantics
|
|
15
|
+
// probe: does re-import by uuid update in place, or append?
|
|
16
|
+
//
|
|
17
|
+
// Import-back is opt-in (--edit) because import semantics on a whole-graph
|
|
18
|
+
// re-import are exactly what this stage exists to LEARN — it may merge, it may
|
|
19
|
+
// duplicate id-less blocks. The script never touches a graph unless told to.
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import { mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync } from "node:fs";
|
|
22
|
+
import { dirname, join, resolve } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { parseEDNString } from "edn-data";
|
|
25
|
+
import { ednToGemlFiles, gemlFilesToEdn } from "../../core/src/mapping.mjs";
|
|
26
|
+
import { parse, addressedUnits, sliceUnit } from "../../../../geml-parser/dist/geml.js";
|
|
27
|
+
|
|
28
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const out = join(here, "..", "out");
|
|
30
|
+
const GEML = resolve(here, "..", "..", "..", "geml-parser", "dist", "geml.js");
|
|
31
|
+
const lib = { parse, addressedUnits, sliceUnit };
|
|
32
|
+
|
|
33
|
+
const [graph, ...flags] = process.argv.slice(2);
|
|
34
|
+
if (!graph) { console.error("usage: node bin/live-roundtrip.mjs <graph-name> [--edit]"); process.exit(2); }
|
|
35
|
+
const doEdit = flags.includes("--edit");
|
|
36
|
+
|
|
37
|
+
// The CLI is driven through npx so nothing here depends on a global install;
|
|
38
|
+
// LOGSEQ_CLI_DIR points at a directory whose node_modules has @logseq/cli.
|
|
39
|
+
const cliCwd = process.env.LOGSEQ_CLI_DIR ?? join(here, "..");
|
|
40
|
+
const logseq = (...args) =>
|
|
41
|
+
execFileSync("npx", ["-y", "@logseq/cli", ...args], { cwd: cliCwd, encoding: "utf8", shell: true, maxBuffer: 1 << 28 });
|
|
42
|
+
const geml = (...args) =>
|
|
43
|
+
execFileSync(process.execPath, [GEML, ...args], { encoding: "utf8", maxBuffer: 1 << 28 });
|
|
44
|
+
|
|
45
|
+
// EDN-semantics canonical form (same as the test suite).
|
|
46
|
+
function canon(v) {
|
|
47
|
+
if (Array.isArray(v)) return v.map(canon);
|
|
48
|
+
if (v !== null && typeof v === "object") {
|
|
49
|
+
if (Array.isArray(v.map)) {
|
|
50
|
+
const entries = v.map.map(([k, val]) => [canon(k), canon(val)]);
|
|
51
|
+
entries.sort((a, b) => (JSON.stringify(a[0]) < JSON.stringify(b[0]) ? -1 : 1));
|
|
52
|
+
return { map: entries };
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(v.set)) {
|
|
55
|
+
const items = v.set.map(canon);
|
|
56
|
+
items.sort((a, b) => (JSON.stringify(a) < JSON.stringify(b) ? -1 : 1));
|
|
57
|
+
return { set: items };
|
|
58
|
+
}
|
|
59
|
+
const o = {};
|
|
60
|
+
for (const k of Object.keys(v).sort()) o[k] = canon(v[k]);
|
|
61
|
+
return o;
|
|
62
|
+
}
|
|
63
|
+
return v;
|
|
64
|
+
}
|
|
65
|
+
const same = (a, b) => JSON.stringify(canon(parseEDNString(a))) === JSON.stringify(canon(parseEDNString(b)));
|
|
66
|
+
|
|
67
|
+
rmSync(out, { recursive: true, force: true });
|
|
68
|
+
mkdirSync(join(out, "geml"), { recursive: true });
|
|
69
|
+
|
|
70
|
+
console.log(`1. export-edn from graph "${graph}"`);
|
|
71
|
+
logseq("export-edn", "-g", graph, "-f", join(out, "export-1.edn"));
|
|
72
|
+
const edn1 = readFileSync(join(out, "export-1.edn"), "utf8");
|
|
73
|
+
console.log(` ${edn1.length} bytes of EDN`);
|
|
74
|
+
|
|
75
|
+
console.log("2. EDN -> GEML");
|
|
76
|
+
const files = ednToGemlFiles(edn1);
|
|
77
|
+
for (const [rel, text] of files) {
|
|
78
|
+
mkdirSync(dirname(join(out, "geml", rel)), { recursive: true });
|
|
79
|
+
writeFileSync(join(out, "geml", rel), text);
|
|
80
|
+
}
|
|
81
|
+
console.log(` ${files.size} documents`);
|
|
82
|
+
|
|
83
|
+
console.log("3. geml check on every document");
|
|
84
|
+
let dirty = 0;
|
|
85
|
+
for (const rel of files.keys()) {
|
|
86
|
+
try { geml("check", "--root", join(out, "geml"), join(out, "geml", rel)); }
|
|
87
|
+
catch (e) { dirty++; console.error(` FAIL ${rel}\n${e.stdout ?? ""}${e.stderr ?? ""}`); }
|
|
88
|
+
}
|
|
89
|
+
if (dirty) { console.error(` ${dirty} document(s) not clean — stopping`); process.exit(1); }
|
|
90
|
+
console.log(" all clean");
|
|
91
|
+
|
|
92
|
+
console.log("4. GEML -> EDN");
|
|
93
|
+
const files2 = new Map();
|
|
94
|
+
for (const rel of files.keys()) files2.set(rel, readFileSync(join(out, "geml", rel), "utf8"));
|
|
95
|
+
const edn2 = gemlFilesToEdn(files2, lib);
|
|
96
|
+
writeFileSync(join(out, "import.edn"), edn2);
|
|
97
|
+
|
|
98
|
+
console.log("5. structural identity, on the real graph");
|
|
99
|
+
if (!same(edn1, edn2)) { console.error(" NOT identical — diff out/export-1.edn against out/import.edn"); process.exit(1); }
|
|
100
|
+
console.log(" identical (EDN semantics)");
|
|
101
|
+
|
|
102
|
+
if (!doEdit) { console.log("\nround trip holds. Re-run with --edit to probe import-back semantics."); process.exit(0); }
|
|
103
|
+
|
|
104
|
+
console.log("6. edit one uuid block via `geml set`, import back, validate");
|
|
105
|
+
const withUuid = [...files.keys()].map((rel) => {
|
|
106
|
+
const text = files2.get(rel);
|
|
107
|
+
const unit = [...addressedUnits(text)].map((a) => a.unit).find((u) => u.kind === "block" && u.id && /^[0-9a-f-]{36}$/.test(u.id));
|
|
108
|
+
return unit ? { rel, unit } : null;
|
|
109
|
+
}).find(Boolean);
|
|
110
|
+
if (!withUuid) { console.log(" no uuid-bearing block in this graph (nothing referenced) — skipping the edit probe"); process.exit(0); }
|
|
111
|
+
|
|
112
|
+
const target = join(out, "geml", withUuid.rel);
|
|
113
|
+
// Keep the block's own head line (type, id, level) — the edit is to the BODY.
|
|
114
|
+
const src = files2.get(withUuid.rel);
|
|
115
|
+
const head = sliceUnit(src, withUuid.unit.span, "head").trimEnd();
|
|
116
|
+
const body = sliceUnit(src, withUuid.unit.span, "body").trimEnd();
|
|
117
|
+
const fence = head.match(/^=+/)[0];
|
|
118
|
+
writeFileSync(join(out, "edit.txt"), `${head}\n${body} — edited by geml\n${fence}\n`);
|
|
119
|
+
geml("set", target, `#${withUuid.unit.id}`, "--in", join(out, "edit.txt"), "--root", join(out, "geml"));
|
|
120
|
+
console.log(` edited #${withUuid.unit.id} in ${withUuid.rel}`);
|
|
121
|
+
|
|
122
|
+
const files3 = new Map();
|
|
123
|
+
for (const rel of files.keys()) files3.set(rel, readFileSync(join(out, "geml", rel), "utf8"));
|
|
124
|
+
writeFileSync(join(out, "import-edited.edn"), gemlFilesToEdn(files3, lib));
|
|
125
|
+
logseq("import-edn", "-g", graph, "-f", join(out, "import-edited.edn"));
|
|
126
|
+
console.log(" imported");
|
|
127
|
+
console.log(logseq("validate", "-g", graph).trim());
|
|
128
|
+
|
|
129
|
+
console.log("7. export again — inspect out/export-2.edn to judge merge semantics");
|
|
130
|
+
logseq("export-edn", "-g", graph, "-f", join(out, "export-2.edn"));
|
|
131
|
+
console.log("done — compare out/export-1.edn / out/export-2.edn");
|