@geml/logseq-sync 2.0.8 → 2.0.9
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 -28
- package/README.md +10 -2
- package/core/src/bridge.mjs +8 -8
- package/core/src/discovery.mjs +204 -204
- package/core/src/sync-engine.mjs +111 -11
- package/docs/how-it-works.svg +42 -42
- package/package.json +58 -54
- 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 +23 -1
package/core/src/sync-engine.mjs
CHANGED
|
@@ -19,6 +19,11 @@ import { ednToGemlFiles, gemlFilesToEdn } from "./mapping.mjs";
|
|
|
19
19
|
import { gemlToOgMarkdown } from "./og-markdown.mjs";
|
|
20
20
|
|
|
21
21
|
const MANIFEST_FILE = ".geml-manifest.json";
|
|
22
|
+
// The Markdown tree needs a ledger of its own: `--markdown` takes ANY
|
|
23
|
+
// directory, so it is the tree most likely to be pointed at a graph someone
|
|
24
|
+
// already has, and it must know which .md files are its own writes before it
|
|
25
|
+
// overwrites one. A separate file because markdownDir may BE targetDir.
|
|
26
|
+
const MD_MANIFEST_FILE = ".geml-md-manifest.json";
|
|
22
27
|
|
|
23
28
|
const sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
24
29
|
|
|
@@ -31,8 +36,8 @@ const sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
|
31
36
|
* watcher's own last write, not something a person or agent changed.
|
|
32
37
|
* @returns {{ known: boolean, hashed: boolean, files: Map<string, string|null> }}
|
|
33
38
|
*/
|
|
34
|
-
function readManifest(targetDir) {
|
|
35
|
-
const manifestPath = join(targetDir,
|
|
39
|
+
function readManifest(targetDir, manifestFile = MANIFEST_FILE) {
|
|
40
|
+
const manifestPath = join(targetDir, manifestFile);
|
|
36
41
|
if (!existsSync(manifestPath)) return { known: false, hashed: false, files: new Map() };
|
|
37
42
|
try {
|
|
38
43
|
const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
@@ -159,6 +164,38 @@ export function readGemlFilesFromDisk(dir, baseDir = dir) {
|
|
|
159
164
|
return files;
|
|
160
165
|
}
|
|
161
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Count the Markdown pages of an OG graph laid out under dir.
|
|
169
|
+
* readGemlFilesFromDisk answers "what of ours is here"; this answers "is this
|
|
170
|
+
* already somebody's graph", which is a different question and the one the
|
|
171
|
+
* empty-export guard actually asks.
|
|
172
|
+
*/
|
|
173
|
+
function countMarkdownPages(dir) {
|
|
174
|
+
let count = 0;
|
|
175
|
+
for (const sub of ["pages", "journals"]) {
|
|
176
|
+
const root = join(dir, sub);
|
|
177
|
+
if (!existsSync(root)) continue;
|
|
178
|
+
const stack = [root];
|
|
179
|
+
while (stack.length > 0) {
|
|
180
|
+
const current = stack.pop();
|
|
181
|
+
let entries;
|
|
182
|
+
try {
|
|
183
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
184
|
+
} catch {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
for (const entry of entries) {
|
|
188
|
+
if (entry.isDirectory()) {
|
|
189
|
+
if (!entry.name.startsWith(".") && entry.name !== "node_modules") stack.push(join(current, entry.name));
|
|
190
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
191
|
+
count += 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return count;
|
|
197
|
+
}
|
|
198
|
+
|
|
162
199
|
/**
|
|
163
200
|
* Incrementally sync a Map of GEML files to disk.
|
|
164
201
|
* Only writes files whose content has changed or do not yet exist.
|
|
@@ -176,16 +213,22 @@ export function readGemlFilesFromDisk(dir, baseDir = dir) {
|
|
|
176
213
|
* @param {string[]} [opts.preserve] Files NOT to overwrite even when the graph
|
|
177
214
|
* differs — the conflicted files of a two-way cycle. Their manifest entry
|
|
178
215
|
* keeps its previous hash, so they stay flagged until a person resolves them.
|
|
179
|
-
* @
|
|
216
|
+
* @param {boolean} [opts.overwriteUnmanaged=false] Overwrite files that exist
|
|
217
|
+
* on disk but that no manifest ever claimed. Off by default: a vault IS a
|
|
218
|
+
* graph, so people point this at one they already have, and a file we never
|
|
219
|
+
* wrote is theirs, not our own echo.
|
|
220
|
+
* @returns {{ written: string[], orphaned: string[], unchanged: string[], deleted: string[], preserved: string[], unmanaged: string[] }}
|
|
180
221
|
*/
|
|
181
222
|
export function writeGemlFilesToDisk(gemlFiles, targetDir, opts = {}) {
|
|
182
223
|
const deleteOrphans = opts.deleteOrphans ?? false;
|
|
224
|
+
const overwriteUnmanaged = opts.overwriteUnmanaged ?? false;
|
|
183
225
|
const preserve = new Set(opts.preserve ?? []);
|
|
184
226
|
const written = [];
|
|
185
227
|
const unchanged = [];
|
|
186
228
|
const orphaned = [];
|
|
187
229
|
const deleted = [];
|
|
188
230
|
const preserved = [];
|
|
231
|
+
const unmanaged = [];
|
|
189
232
|
|
|
190
233
|
mkdirSync(targetDir, { recursive: true });
|
|
191
234
|
const existingFiles = readGemlFilesFromDisk(targetDir);
|
|
@@ -204,6 +247,17 @@ export function writeGemlFilesToDisk(gemlFiles, targetDir, opts = {}) {
|
|
|
204
247
|
if (preserve.has(rel)) {
|
|
205
248
|
if (existingContent !== normNew) preserved.push(rel);
|
|
206
249
|
else unchanged.push(rel);
|
|
250
|
+
} else if (
|
|
251
|
+
existingContent !== undefined &&
|
|
252
|
+
existingContent !== normNew &&
|
|
253
|
+
!lastManifest.has(rel) &&
|
|
254
|
+
!overwriteUnmanaged
|
|
255
|
+
) {
|
|
256
|
+
// On disk, different from the graph, and no manifest ever claimed it:
|
|
257
|
+
// someone else's file, not our own echo. A file that already MATCHES
|
|
258
|
+
// what we would write falls through and is adopted — identical bytes
|
|
259
|
+
// mean there is nothing of theirs to lose.
|
|
260
|
+
unmanaged.push(rel);
|
|
207
261
|
} else if (existingContent === undefined || existingContent !== normNew) {
|
|
208
262
|
atomicWriteFileSync(fullPath, normNew);
|
|
209
263
|
written.push(rel);
|
|
@@ -235,6 +289,9 @@ export function writeGemlFilesToDisk(gemlFiles, targetDir, opts = {}) {
|
|
|
235
289
|
// detectExternalEdits() compares against, so the watcher's own writes never
|
|
236
290
|
// read as someone else's edits.
|
|
237
291
|
const currentManifest = new Set(gemlFiles.keys());
|
|
292
|
+
// A held file stays unowned: recording a hash for it would make the next run
|
|
293
|
+
// read the person's content as the sync's own last write and clobber it.
|
|
294
|
+
for (const rel of unmanaged) currentManifest.delete(rel);
|
|
238
295
|
for (const rel of lastManifest) {
|
|
239
296
|
if (existingFiles.has(rel) && !deleted.includes(rel)) {
|
|
240
297
|
currentManifest.add(rel);
|
|
@@ -254,7 +311,7 @@ export function writeGemlFilesToDisk(gemlFiles, targetDir, opts = {}) {
|
|
|
254
311
|
}
|
|
255
312
|
atomicWriteFileSync(manifestPath, JSON.stringify({ version: 2, files: manifestFiles }, null, 1) + "\n");
|
|
256
313
|
|
|
257
|
-
return { written, orphaned, unchanged, deleted, preserved };
|
|
314
|
+
return { written, orphaned, unchanged, deleted, preserved, unmanaged };
|
|
258
315
|
}
|
|
259
316
|
|
|
260
317
|
/**
|
|
@@ -364,10 +421,14 @@ export async function gitAutoCommit(targetDir, commitMessage = "logseq-geml sync
|
|
|
364
421
|
* @param {boolean} [opts.autoCommit=false]
|
|
365
422
|
* @param {string} [opts.commitMessage]
|
|
366
423
|
* @param {boolean} [opts.allowEmptyGraph=false] Refuse 0-page export over non-empty targetDir unless true.
|
|
424
|
+
* @param {boolean} [opts.overwriteUnmanaged=false] Overwrite files no manifest
|
|
425
|
+
* ever claimed, in BOTH trees. Off by default — see writeGemlFilesToDisk.
|
|
367
426
|
* @param {function} [opts.gitRunner]
|
|
368
|
-
* @returns {Promise<{ written: string[], deleted: string[], orphaned: string[], unchanged: string[], gitResult?: any }>}
|
|
427
|
+
* @returns {Promise<{ written: string[], deleted: string[], orphaned: string[], unchanged: string[], unmanaged: string[], markdownWritten: string[], markdownUnmanaged: string[], gitResult?: any }>}
|
|
369
428
|
*/
|
|
370
429
|
export async function syncEdnToDisk(ednText, targetDir, opts = {}) {
|
|
430
|
+
const overwriteUnmanaged = opts.overwriteUnmanaged ?? false;
|
|
431
|
+
|
|
371
432
|
// Guard 1: Refuse empty or truncated EDN input
|
|
372
433
|
if (!ednText || typeof ednText !== "string" || ednText.trim().length === 0) {
|
|
373
434
|
throw new Error("EDN input is empty or truncated; refusing to sync to prevent data loss.");
|
|
@@ -378,7 +439,14 @@ export async function syncEdnToDisk(ednText, targetDir, opts = {}) {
|
|
|
378
439
|
// Guard 2: Refuse 0-page export if targetDir already has existing pages
|
|
379
440
|
const pageCount = [...gemlFiles.keys()].filter((k) => k.startsWith("pages/") || k.startsWith("journals/")).length;
|
|
380
441
|
const existingFiles = readGemlFilesFromDisk(targetDir);
|
|
381
|
-
|
|
442
|
+
// Counting only .geml left this guard blind to exactly what it guards
|
|
443
|
+
// against: a directory that is ALREADY an OG graph holds its pages as
|
|
444
|
+
// Markdown, and readGemlFilesFromDisk does not see one of them.
|
|
445
|
+
const markdownDir = opts.markdownDir ?? null;
|
|
446
|
+
const existingPageCount =
|
|
447
|
+
[...existingFiles.keys()].filter((k) => k.startsWith("pages/") || k.startsWith("journals/")).length +
|
|
448
|
+
countMarkdownPages(targetDir) +
|
|
449
|
+
(markdownDir && resolve(markdownDir) !== resolve(targetDir) ? countMarkdownPages(markdownDir) : 0);
|
|
382
450
|
|
|
383
451
|
if (pageCount === 0 && existingPageCount > 0 && !opts.allowEmptyGraph) {
|
|
384
452
|
throw new Error(
|
|
@@ -399,13 +467,16 @@ export async function syncEdnToDisk(ednText, targetDir, opts = {}) {
|
|
|
399
467
|
// round-trips, and `restore` never reads this. The parser library is
|
|
400
468
|
// injected, so core keeps its single dependency.
|
|
401
469
|
const markdownWritten = [];
|
|
402
|
-
|
|
470
|
+
const markdownUnmanaged = [];
|
|
471
|
+
if (markdownDir && opts.lib) {
|
|
472
|
+
const mdPrevious = readManifest(markdownDir, MD_MANIFEST_FILE);
|
|
473
|
+
const mdManifest = {};
|
|
403
474
|
for (const [rel, content] of gemlFiles) {
|
|
404
475
|
// Only pages and journals are a graph; the index and the ontology carry
|
|
405
476
|
// machine bookkeeping OG has no page for.
|
|
406
477
|
if (!rel.startsWith("pages/") && !rel.startsWith("journals/")) continue;
|
|
407
478
|
const mdRel = rel.replace(/\.geml$/, ".md");
|
|
408
|
-
const full = join(
|
|
479
|
+
const full = join(markdownDir, mdRel);
|
|
409
480
|
let md;
|
|
410
481
|
try {
|
|
411
482
|
md = normalizeEol(gemlToOgMarkdown(content, opts.lib));
|
|
@@ -413,18 +484,46 @@ export async function syncEdnToDisk(ednText, targetDir, opts = {}) {
|
|
|
413
484
|
continue; // one unconvertible document must not fail the sync
|
|
414
485
|
}
|
|
415
486
|
if (md === "") continue; // nothing OG can hold — write no file
|
|
416
|
-
|
|
417
|
-
if (
|
|
487
|
+
const onDisk = existsSync(full) ? normalizeEol(readFileSync(full, "utf8")) : null;
|
|
488
|
+
if (onDisk !== null && onDisk !== md && !mdPrevious.files.has(mdRel) && !overwriteUnmanaged) {
|
|
489
|
+
// The GEML tree's rule, and this tree needs it more: `--markdown` takes
|
|
490
|
+
// any directory, so somebody's own pages/*.md is precisely what it
|
|
491
|
+
// lands on. Held, named, and left exactly as they wrote it.
|
|
492
|
+
markdownUnmanaged.push(mdRel);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (onDisk !== md) {
|
|
496
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
418
497
|
atomicWriteFileSync(full, md);
|
|
419
498
|
markdownWritten.push(mdRel);
|
|
420
499
|
}
|
|
500
|
+
mdManifest[mdRel] = sha256(md);
|
|
501
|
+
}
|
|
502
|
+
// A page the graph stopped exporting keeps its entry while the file is
|
|
503
|
+
// still there: dropping it would make our own past write read as a
|
|
504
|
+
// stranger's on the next run, and the sync would refuse to touch it.
|
|
505
|
+
for (const [mdRel, hash] of mdPrevious.files) {
|
|
506
|
+
if (!(mdRel in mdManifest) && existsSync(join(markdownDir, mdRel))) mdManifest[mdRel] = hash;
|
|
507
|
+
}
|
|
508
|
+
const mdManifestPath = join(markdownDir, MD_MANIFEST_FILE);
|
|
509
|
+
const mdManifestFiles = {};
|
|
510
|
+
for (const mdRel of Object.keys(mdManifest).sort()) mdManifestFiles[mdRel] = mdManifest[mdRel];
|
|
511
|
+
const mdManifestText = JSON.stringify({ version: 2, files: mdManifestFiles }, null, 1) + "\n";
|
|
512
|
+
const mdManifestExists = existsSync(mdManifestPath);
|
|
513
|
+
// Only when it actually changed: this file lives inside someone's OG graph,
|
|
514
|
+
// and rewriting it every poll would have Logseq re-reading it forever.
|
|
515
|
+
if (
|
|
516
|
+
(Object.keys(mdManifestFiles).length > 0 || mdManifestExists) &&
|
|
517
|
+
(!mdManifestExists || readFileSync(mdManifestPath, "utf8") !== mdManifestText)
|
|
518
|
+
) {
|
|
519
|
+
atomicWriteFileSync(mdManifestPath, mdManifestText);
|
|
421
520
|
}
|
|
422
521
|
}
|
|
423
522
|
|
|
424
523
|
let gitResult = null;
|
|
425
524
|
const pathsModified = [...diffResult.written, ...diffResult.deleted];
|
|
426
525
|
for (const rel of markdownWritten) {
|
|
427
|
-
const abs = join(
|
|
526
|
+
const abs = join(markdownDir, rel);
|
|
428
527
|
const insideVault = relative(targetDir, abs);
|
|
429
528
|
if (insideVault && !insideVault.startsWith("..") && !isAbsolute(insideVault)) {
|
|
430
529
|
pathsModified.push(insideVault);
|
|
@@ -439,6 +538,7 @@ export async function syncEdnToDisk(ednText, targetDir, opts = {}) {
|
|
|
439
538
|
return {
|
|
440
539
|
...diffResult,
|
|
441
540
|
markdownWritten,
|
|
541
|
+
markdownUnmanaged,
|
|
442
542
|
gitResult,
|
|
443
543
|
};
|
|
444
544
|
}
|
package/docs/how-it-works.svg
CHANGED
|
@@ -1,42 +1,42 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 250" font-family="ui-sans-serif,system-ui,Segoe UI,Helvetica,Arial,sans-serif">
|
|
2
|
-
<defs>
|
|
3
|
-
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
4
|
-
<path d="M0,0 L10,5 L0,10 z" fill="#8b949e"/>
|
|
5
|
-
</marker>
|
|
6
|
-
</defs>
|
|
7
|
-
<rect x="0" y="0" width="760" height="250" rx="12" fill="#f6f8fa" stroke="#d0d7de"/>
|
|
8
|
-
|
|
9
|
-
<!-- Logseq app box -->
|
|
10
|
-
<rect x="24" y="36" width="220" height="130" rx="10" fill="#ffffff" stroke="#a475f9" stroke-width="1.5"/>
|
|
11
|
-
<text x="134" y="62" text-anchor="middle" font-size="15" font-weight="700" fill="#57606a">Logseq 2.0 (DB graph)</text>
|
|
12
|
-
<rect x="44" y="80" width="180" height="66" rx="8" fill="#f3ecff" stroke="#a475f9"/>
|
|
13
|
-
<text x="134" y="104" text-anchor="middle" font-size="13" font-weight="600" fill="#3b2a63">Sync Vault with GEML plugin</text>
|
|
14
|
-
<text x="134" y="124" text-anchor="middle" font-size="11.5" fill="#57606a">DB.onChanged → debounce</text>
|
|
15
|
-
<text x="134" y="139" text-anchor="middle" font-size="11.5" fill="#57606a">status in toolbar ⇄</text>
|
|
16
|
-
|
|
17
|
-
<!-- signal file -->
|
|
18
|
-
<rect x="286" y="70" width="160" height="42" rx="8" fill="#fff8c5" stroke="#d4a72c"/>
|
|
19
|
-
<text x="366" y="88" text-anchor="middle" font-size="12" font-weight="600" fill="#57606a">dirty-marker file</text>
|
|
20
|
-
<text x="366" y="103" text-anchor="middle" font-size="10.5" fill="#7d8590">…/storages/logseq-plugin-sync-vault-with-geml/</text>
|
|
21
|
-
|
|
22
|
-
<!-- status file -->
|
|
23
|
-
<rect x="286" y="128" width="160" height="34" rx="8" fill="#ddf4ff" stroke="#54aeff"/>
|
|
24
|
-
<text x="366" y="149" text-anchor="middle" font-size="12" font-weight="600" fill="#57606a">geml-sync-status.json</text>
|
|
25
|
-
|
|
26
|
-
<!-- watcher box -->
|
|
27
|
-
<rect x="488" y="36" width="248" height="130" rx="10" fill="#ffffff" stroke="#2da44e" stroke-width="1.5"/>
|
|
28
|
-
<text x="612" y="62" text-anchor="middle" font-size="15" font-weight="700" fill="#57606a">logseq-sync watcher (CLI)</text>
|
|
29
|
-
<text x="612" y="86" text-anchor="middle" font-size="11.5" fill="#57606a">export via official @logseq/cli</text>
|
|
30
|
-
<text x="612" y="103" text-anchor="middle" font-size="11.5" fill="#57606a">writes only files that changed</text>
|
|
31
|
-
<text x="612" y="120" text-anchor="middle" font-size="11.5" fill="#57606a">git commit scoped to the vault</text>
|
|
32
|
-
<text x="612" y="145" text-anchor="middle" font-size="11.5" font-weight="600" fill="#2da44e">your-vault/pages/*.geml · git</text>
|
|
33
|
-
|
|
34
|
-
<!-- arrows -->
|
|
35
|
-
<line x1="244" y1="91" x2="284" y2="91" stroke="#8b949e" stroke-width="1.6" marker-end="url(#arr)"/>
|
|
36
|
-
<line x1="446" y1="91" x2="486" y2="91" stroke="#8b949e" stroke-width="1.6" marker-end="url(#arr)"/>
|
|
37
|
-
<line x1="486" y1="145" x2="446" y2="145" stroke="#8b949e" stroke-width="1.6" marker-end="url(#arr)"/>
|
|
38
|
-
<line x1="286" y1="145" x2="246" y2="145" stroke="#8b949e" stroke-width="1.6" marker-end="url(#arr)"/>
|
|
39
|
-
|
|
40
|
-
<text x="380" y="196" text-anchor="middle" font-size="12.5" fill="#57606a">The plugin is a doorbell, not the mover: the sandbox has no filesystem and no git,</text>
|
|
41
|
-
<text x="380" y="214" text-anchor="middle" font-size="12.5" fill="#57606a">so everything with side effects lives in the watcher — auditable, scoped, outside the app.</text>
|
|
42
|
-
</svg>
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 250" font-family="ui-sans-serif,system-ui,Segoe UI,Helvetica,Arial,sans-serif">
|
|
2
|
+
<defs>
|
|
3
|
+
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
4
|
+
<path d="M0,0 L10,5 L0,10 z" fill="#8b949e"/>
|
|
5
|
+
</marker>
|
|
6
|
+
</defs>
|
|
7
|
+
<rect x="0" y="0" width="760" height="250" rx="12" fill="#f6f8fa" stroke="#d0d7de"/>
|
|
8
|
+
|
|
9
|
+
<!-- Logseq app box -->
|
|
10
|
+
<rect x="24" y="36" width="220" height="130" rx="10" fill="#ffffff" stroke="#a475f9" stroke-width="1.5"/>
|
|
11
|
+
<text x="134" y="62" text-anchor="middle" font-size="15" font-weight="700" fill="#57606a">Logseq 2.0 (DB graph)</text>
|
|
12
|
+
<rect x="44" y="80" width="180" height="66" rx="8" fill="#f3ecff" stroke="#a475f9"/>
|
|
13
|
+
<text x="134" y="104" text-anchor="middle" font-size="13" font-weight="600" fill="#3b2a63">Sync Vault with GEML plugin</text>
|
|
14
|
+
<text x="134" y="124" text-anchor="middle" font-size="11.5" fill="#57606a">DB.onChanged → debounce</text>
|
|
15
|
+
<text x="134" y="139" text-anchor="middle" font-size="11.5" fill="#57606a">status in toolbar ⇄</text>
|
|
16
|
+
|
|
17
|
+
<!-- signal file -->
|
|
18
|
+
<rect x="286" y="70" width="160" height="42" rx="8" fill="#fff8c5" stroke="#d4a72c"/>
|
|
19
|
+
<text x="366" y="88" text-anchor="middle" font-size="12" font-weight="600" fill="#57606a">dirty-marker file</text>
|
|
20
|
+
<text x="366" y="103" text-anchor="middle" font-size="10.5" fill="#7d8590">…/storages/logseq-plugin-sync-vault-with-geml/</text>
|
|
21
|
+
|
|
22
|
+
<!-- status file -->
|
|
23
|
+
<rect x="286" y="128" width="160" height="34" rx="8" fill="#ddf4ff" stroke="#54aeff"/>
|
|
24
|
+
<text x="366" y="149" text-anchor="middle" font-size="12" font-weight="600" fill="#57606a">geml-sync-status.json</text>
|
|
25
|
+
|
|
26
|
+
<!-- watcher box -->
|
|
27
|
+
<rect x="488" y="36" width="248" height="130" rx="10" fill="#ffffff" stroke="#2da44e" stroke-width="1.5"/>
|
|
28
|
+
<text x="612" y="62" text-anchor="middle" font-size="15" font-weight="700" fill="#57606a">logseq-sync watcher (CLI)</text>
|
|
29
|
+
<text x="612" y="86" text-anchor="middle" font-size="11.5" fill="#57606a">export via official @logseq/cli</text>
|
|
30
|
+
<text x="612" y="103" text-anchor="middle" font-size="11.5" fill="#57606a">writes only files that changed</text>
|
|
31
|
+
<text x="612" y="120" text-anchor="middle" font-size="11.5" fill="#57606a">git commit scoped to the vault</text>
|
|
32
|
+
<text x="612" y="145" text-anchor="middle" font-size="11.5" font-weight="600" fill="#2da44e">your-vault/pages/*.geml · git</text>
|
|
33
|
+
|
|
34
|
+
<!-- arrows -->
|
|
35
|
+
<line x1="244" y1="91" x2="284" y2="91" stroke="#8b949e" stroke-width="1.6" marker-end="url(#arr)"/>
|
|
36
|
+
<line x1="446" y1="91" x2="486" y2="91" stroke="#8b949e" stroke-width="1.6" marker-end="url(#arr)"/>
|
|
37
|
+
<line x1="486" y1="145" x2="446" y2="145" stroke="#8b949e" stroke-width="1.6" marker-end="url(#arr)"/>
|
|
38
|
+
<line x1="286" y1="145" x2="246" y2="145" stroke="#8b949e" stroke-width="1.6" marker-end="url(#arr)"/>
|
|
39
|
+
|
|
40
|
+
<text x="380" y="196" text-anchor="middle" font-size="12.5" fill="#57606a">The plugin is a doorbell, not the mover: the sandbox has no filesystem and no git,</text>
|
|
41
|
+
<text x="380" y="214" text-anchor="middle" font-size="12.5" fill="#57606a">so everything with side effects lives in the watcher — auditable, scoped, outside the app.</text>
|
|
42
|
+
</svg>
|
package/package.json
CHANGED
|
@@ -1,54 +1,58 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@geml/logseq-sync",
|
|
3
|
-
"version": "2.0.
|
|
4
|
-
"publishConfig": {
|
|
5
|
-
"access": "public"
|
|
6
|
-
},
|
|
7
|
-
"description": "Continuously sync a Logseq DB graph to a Git-friendly folder of readable plain-text GEML files — the watcher half of the Sync Vault with GEML plugin. Built on the official @logseq/cli export; writes only files that changed, commits scoped strictly to the vault.",
|
|
8
|
-
"type": "module",
|
|
9
|
-
"bin": {
|
|
10
|
-
"logseq-sync": "watcher/bin/logseq-sync.mjs"
|
|
11
|
-
},
|
|
12
|
-
"files": [
|
|
13
|
-
"core/src",
|
|
14
|
-
"watcher/bin",
|
|
15
|
-
"docs",
|
|
16
|
-
"README.md",
|
|
17
|
-
"LICENSE"
|
|
18
|
-
],
|
|
19
|
-
"engines": {
|
|
20
|
-
"node": ">=22"
|
|
21
|
-
},
|
|
22
|
-
"keywords": [
|
|
23
|
-
"logseq",
|
|
24
|
-
"logseq-plugin",
|
|
25
|
-
"sync",
|
|
26
|
-
"git",
|
|
27
|
-
"plain-text",
|
|
28
|
-
"geml",
|
|
29
|
-
"vault",
|
|
30
|
-
"backup"
|
|
31
|
-
],
|
|
32
|
-
"repository": {
|
|
33
|
-
"type": "git",
|
|
34
|
-
"url": "git+https://github.com/geml-spec/geml.git",
|
|
35
|
-
"directory": "integrations/logseq"
|
|
36
|
-
},
|
|
37
|
-
"homepage": "https://github.com/geml-spec/logseq-plugin-sync-vault-with-geml#readme",
|
|
38
|
-
"bugs": {
|
|
39
|
-
"url": "https://github.com/geml-spec/logseq-plugin-sync-vault-with-geml/issues"
|
|
40
|
-
},
|
|
41
|
-
"license": "MIT",
|
|
42
|
-
"workspaces": [
|
|
43
|
-
"plugin"
|
|
44
|
-
],
|
|
45
|
-
"scripts": {
|
|
46
|
-
"test": "node core/test/roundtrip.test.mjs && node core/test/sync.test.mjs && node core/test/discovery.test.mjs && node watcher/test/cli-sync.test.mjs && node watcher/test/signal-sync.test.mjs && node watcher/test/zero-config.test.mjs && node plugin/test/core.test.mjs",
|
|
47
|
-
"sync": "node watcher/bin/logseq-sync.mjs",
|
|
48
|
-
"build:plugin": "node plugin/build.mjs"
|
|
49
|
-
},
|
|
50
|
-
"dependencies": {
|
|
51
|
-
"@geml/geml": "^1.8.8",
|
|
52
|
-
"edn-data": "^1.2.2"
|
|
53
|
-
}
|
|
54
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@geml/logseq-sync",
|
|
3
|
+
"version": "2.0.9",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Continuously sync a Logseq DB graph to a Git-friendly folder of readable plain-text GEML files — the watcher half of the Sync Vault with GEML plugin. Built on the official @logseq/cli export; writes only files that changed, commits scoped strictly to the vault.",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"logseq-sync": "watcher/bin/logseq-sync.mjs"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"core/src",
|
|
14
|
+
"watcher/bin",
|
|
15
|
+
"docs",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"logseq",
|
|
24
|
+
"logseq-plugin",
|
|
25
|
+
"sync",
|
|
26
|
+
"git",
|
|
27
|
+
"plain-text",
|
|
28
|
+
"geml",
|
|
29
|
+
"vault",
|
|
30
|
+
"backup"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/geml-spec/geml.git",
|
|
35
|
+
"directory": "integrations/logseq"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/geml-spec/logseq-plugin-sync-vault-with-geml#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/geml-spec/logseq-plugin-sync-vault-with-geml/issues"
|
|
40
|
+
},
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"workspaces": [
|
|
43
|
+
"plugin"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "node core/test/roundtrip.test.mjs && node core/test/sync.test.mjs && node core/test/discovery.test.mjs && node watcher/test/cli-sync.test.mjs && node watcher/test/signal-sync.test.mjs && node watcher/test/zero-config.test.mjs && node plugin/test/core.test.mjs",
|
|
47
|
+
"sync": "node watcher/bin/logseq-sync.mjs",
|
|
48
|
+
"build:plugin": "node plugin/build.mjs"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@geml/geml": "^1.8.8",
|
|
52
|
+
"edn-data": "^1.2.2"
|
|
53
|
+
},
|
|
54
|
+
"overrides": {
|
|
55
|
+
"dompurify": "^3.4.14",
|
|
56
|
+
"lodash-es": "^4.18.1"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -1,51 +1,51 @@
|
|
|
1
|
-
// Create an empty Logseq DB graph WITHOUT the desktop app.
|
|
2
|
-
//
|
|
3
|
-
// LOGSEQ_CLI_DIR=<dir whose node_modules holds @logseq/cli> \
|
|
4
|
-
// node bin/create-graph.mjs <graph-name> (name travels via GEML_GRAPH_NAME —
|
|
5
|
-
// nbb loadFile does not surface *command-line-args*)
|
|
6
|
-
//
|
|
7
|
-
// Why this exists: `@logseq/cli` (0.4.3) can export, import and validate a DB
|
|
8
|
-
// graph, but cannot create one — creation lives in the desktop app, and on a
|
|
9
|
-
// machine where the app cannot run (permissions, CI) that is a dead end. The
|
|
10
|
-
// CLI package VENDORS the whole logseq.db stack though, and its `open-db!`
|
|
11
|
-
// creates the sqlite tables on open; the only other thing the app does at
|
|
12
|
-
// create time is transact `build-db-initial-data`. So this does exactly those
|
|
13
|
-
// two steps, through the CLI's own vendored code — the resulting graph is one
|
|
14
|
-
// `logseq list/show/validate` accepts as its own (verified: schema 65.22,
|
|
15
|
-
// "Valid!").
|
|
16
|
-
import { fileURLToPath, pathToFileURL } from "url";
|
|
17
|
-
import { dirname, resolve } from "path";
|
|
18
|
-
import { existsSync, readFileSync, copyFileSync, rmSync } from "fs";
|
|
19
|
-
|
|
20
|
-
const here = fileURLToPath(dirname(import.meta.url));
|
|
21
|
-
const cliDir = process.env.LOGSEQ_CLI_DIR ?? resolve(here, "..");
|
|
22
|
-
const CLI = resolve(cliDir, "node_modules", "@logseq", "cli");
|
|
23
|
-
if (!existsSync(CLI)) {
|
|
24
|
-
console.error(`@logseq/cli not found under ${cliDir}/node_modules — set LOGSEQ_CLI_DIR to a directory where it is installed.`);
|
|
25
|
-
console.error("Note: on Node 24 its better-sqlite3 needs an override to >=12.11.1 for a prebuilt binding.");
|
|
26
|
-
process.exit(2);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// nbb-logseq is resolved from the CLI's install, not from this package: ESM
|
|
30
|
-
// import specifiers resolve relative to THIS file, which would demand a local
|
|
31
|
-
// install of a runtime the CLI already carries.
|
|
32
|
-
const nbbDir = resolve(cliDir, "node_modules", "@logseq", "nbb-logseq");
|
|
33
|
-
const nbbPkg = JSON.parse(readFileSync(resolve(nbbDir, "package.json"), "utf8"));
|
|
34
|
-
const entry = typeof nbbPkg.exports === "object"
|
|
35
|
-
? (nbbPkg.exports["."]?.import ?? nbbPkg.exports["."]) : (nbbPkg.main ?? "index.mjs");
|
|
36
|
-
const { loadFile, addClassPath } = await import(pathToFileURL(resolve(nbbDir, entry)).href);
|
|
37
|
-
|
|
38
|
-
global.__dirname = here;
|
|
39
|
-
addClassPath(resolve(CLI, "src"));
|
|
40
|
-
addClassPath(resolve(CLI, "vendor/src"));
|
|
41
|
-
// nbb resolves the stack's node `require`s (better-sqlite3) relative to the
|
|
42
|
-
// LOADED FILE's directory, so the .cljs must sit beside a node_modules that
|
|
43
|
-
// has them — copy it into the CLI dir for the duration of the run.
|
|
44
|
-
process.env.GEML_GRAPH_NAME = process.argv[2] ?? "geml-spike";
|
|
45
|
-
const staged = resolve(cliDir, ".geml-create-graph.cljs");
|
|
46
|
-
copyFileSync(resolve(here, "create_graph_headless.cljs"), staged);
|
|
47
|
-
try {
|
|
48
|
-
await loadFile(staged);
|
|
49
|
-
} finally {
|
|
50
|
-
rmSync(staged, { force: true });
|
|
51
|
-
}
|
|
1
|
+
// Create an empty Logseq DB graph WITHOUT the desktop app.
|
|
2
|
+
//
|
|
3
|
+
// LOGSEQ_CLI_DIR=<dir whose node_modules holds @logseq/cli> \
|
|
4
|
+
// node bin/create-graph.mjs <graph-name> (name travels via GEML_GRAPH_NAME —
|
|
5
|
+
// nbb loadFile does not surface *command-line-args*)
|
|
6
|
+
//
|
|
7
|
+
// Why this exists: `@logseq/cli` (0.4.3) can export, import and validate a DB
|
|
8
|
+
// graph, but cannot create one — creation lives in the desktop app, and on a
|
|
9
|
+
// machine where the app cannot run (permissions, CI) that is a dead end. The
|
|
10
|
+
// CLI package VENDORS the whole logseq.db stack though, and its `open-db!`
|
|
11
|
+
// creates the sqlite tables on open; the only other thing the app does at
|
|
12
|
+
// create time is transact `build-db-initial-data`. So this does exactly those
|
|
13
|
+
// two steps, through the CLI's own vendored code — the resulting graph is one
|
|
14
|
+
// `logseq list/show/validate` accepts as its own (verified: schema 65.22,
|
|
15
|
+
// "Valid!").
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
17
|
+
import { dirname, resolve } from "path";
|
|
18
|
+
import { existsSync, readFileSync, copyFileSync, rmSync } from "fs";
|
|
19
|
+
|
|
20
|
+
const here = fileURLToPath(dirname(import.meta.url));
|
|
21
|
+
const cliDir = process.env.LOGSEQ_CLI_DIR ?? resolve(here, "..");
|
|
22
|
+
const CLI = resolve(cliDir, "node_modules", "@logseq", "cli");
|
|
23
|
+
if (!existsSync(CLI)) {
|
|
24
|
+
console.error(`@logseq/cli not found under ${cliDir}/node_modules — set LOGSEQ_CLI_DIR to a directory where it is installed.`);
|
|
25
|
+
console.error("Note: on Node 24 its better-sqlite3 needs an override to >=12.11.1 for a prebuilt binding.");
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// nbb-logseq is resolved from the CLI's install, not from this package: ESM
|
|
30
|
+
// import specifiers resolve relative to THIS file, which would demand a local
|
|
31
|
+
// install of a runtime the CLI already carries.
|
|
32
|
+
const nbbDir = resolve(cliDir, "node_modules", "@logseq", "nbb-logseq");
|
|
33
|
+
const nbbPkg = JSON.parse(readFileSync(resolve(nbbDir, "package.json"), "utf8"));
|
|
34
|
+
const entry = typeof nbbPkg.exports === "object"
|
|
35
|
+
? (nbbPkg.exports["."]?.import ?? nbbPkg.exports["."]) : (nbbPkg.main ?? "index.mjs");
|
|
36
|
+
const { loadFile, addClassPath } = await import(pathToFileURL(resolve(nbbDir, entry)).href);
|
|
37
|
+
|
|
38
|
+
global.__dirname = here;
|
|
39
|
+
addClassPath(resolve(CLI, "src"));
|
|
40
|
+
addClassPath(resolve(CLI, "vendor/src"));
|
|
41
|
+
// nbb resolves the stack's node `require`s (better-sqlite3) relative to the
|
|
42
|
+
// LOADED FILE's directory, so the .cljs must sit beside a node_modules that
|
|
43
|
+
// has them — copy it into the CLI dir for the duration of the run.
|
|
44
|
+
process.env.GEML_GRAPH_NAME = process.argv[2] ?? "geml-spike";
|
|
45
|
+
const staged = resolve(cliDir, ".geml-create-graph.cljs");
|
|
46
|
+
copyFileSync(resolve(here, "create_graph_headless.cljs"), staged);
|
|
47
|
+
try {
|
|
48
|
+
await loadFile(staged);
|
|
49
|
+
} finally {
|
|
50
|
+
rmSync(staged, { force: true });
|
|
51
|
+
}
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
(ns create-graph-headless
|
|
2
|
-
"Create an empty Logseq DB graph without the desktop app: the CLI package
|
|
3
|
-
vendors the whole logseq.db stack, and open-db! creates tables on open.
|
|
4
|
-
This does exactly what the app's create-graph does at the db level:
|
|
5
|
-
mkdir + open + transact build-db-initial-data.
|
|
6
|
-
|
|
7
|
-
open-db! creates the DATABASE but refuses a missing DIRECTORY (its error
|
|
8
|
-
says only 'Cannot open database because the directory does not exist'),
|
|
9
|
-
so the mkdir here is load-bearing."
|
|
10
|
-
(:require [logseq.db.common.sqlite-cli :as sqlite-cli]
|
|
11
|
-
[logseq.db.sqlite.create-graph :as sqlite-create-graph]
|
|
12
|
-
[datascript.core :as d]
|
|
13
|
-
["fs" :as fs]
|
|
14
|
-
["os" :as os]
|
|
15
|
-
["path" :as node-path]))
|
|
16
|
-
|
|
17
|
-
(def graph-name (or (aget (.-env js/process) "GEML_GRAPH_NAME") "geml-spike"))
|
|
18
|
-
(def graphs-dir (node-path/join (os/homedir) "logseq" "graphs"))
|
|
19
|
-
(fs/mkdirSync (node-path/join graphs-dir graph-name) #js {:recursive true})
|
|
20
|
-
(def conn (sqlite-cli/open-db! graphs-dir graph-name))
|
|
21
|
-
(d/transact! conn (sqlite-create-graph/build-db-initial-data "{}"))
|
|
22
|
-
(println "created" (node-path/join graphs-dir graph-name))
|
|
1
|
+
(ns create-graph-headless
|
|
2
|
+
"Create an empty Logseq DB graph without the desktop app: the CLI package
|
|
3
|
+
vendors the whole logseq.db stack, and open-db! creates tables on open.
|
|
4
|
+
This does exactly what the app's create-graph does at the db level:
|
|
5
|
+
mkdir + open + transact build-db-initial-data.
|
|
6
|
+
|
|
7
|
+
open-db! creates the DATABASE but refuses a missing DIRECTORY (its error
|
|
8
|
+
says only 'Cannot open database because the directory does not exist'),
|
|
9
|
+
so the mkdir here is load-bearing."
|
|
10
|
+
(:require [logseq.db.common.sqlite-cli :as sqlite-cli]
|
|
11
|
+
[logseq.db.sqlite.create-graph :as sqlite-create-graph]
|
|
12
|
+
[datascript.core :as d]
|
|
13
|
+
["fs" :as fs]
|
|
14
|
+
["os" :as os]
|
|
15
|
+
["path" :as node-path]))
|
|
16
|
+
|
|
17
|
+
(def graph-name (or (aget (.-env js/process) "GEML_GRAPH_NAME") "geml-spike"))
|
|
18
|
+
(def graphs-dir (node-path/join (os/homedir) "logseq" "graphs"))
|
|
19
|
+
(fs/mkdirSync (node-path/join graphs-dir graph-name) #js {:recursive true})
|
|
20
|
+
(def conn (sqlite-cli/open-db! graphs-dir graph-name))
|
|
21
|
+
(d/transact! conn (sqlite-create-graph/build-db-initial-data "{}"))
|
|
22
|
+
(println "created" (node-path/join graphs-dir graph-name))
|