@geml/logseq-sync 2.0.0 → 2.0.3
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 +310 -212
- package/core/src/discovery.mjs +204 -0
- package/core/src/sync-engine.mjs +32 -1
- package/docs/how-it-works.svg +1 -1
- package/docs/screenshot-settings.png +0 -0
- package/docs/screenshot-toolbar.png +0 -0
- package/package.json +54 -53
- package/watcher/bin/logseq-sync.mjs +779 -0
- package/watcher/bin/geml-sync.mjs +0 -290
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Everything the watcher can work out for itself. Each function takes a
|
|
2
|
+
// `probe` — { platform, env, home, exists, read, listDir } — so the logic is
|
|
3
|
+
// testable on any OS without a Logseq installation, and so `doctor` can report
|
|
4
|
+
// exactly what was found and where.
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { SIGNAL_FILE } from "./bridge.mjs";
|
|
7
|
+
|
|
8
|
+
export const PLUGIN_ID = "logseq-plugin-sync-vault-with-geml";
|
|
9
|
+
|
|
10
|
+
// The app bundle path is stable across macOS installs, and the shim the app
|
|
11
|
+
// writes to ~/.local/bin is a two-line wrapper around exactly this pair.
|
|
12
|
+
const MAC_APP = "/Applications/Logseq.app/Contents/MacOS/Logseq";
|
|
13
|
+
const MAC_APP_CLI_JS = "/Applications/Logseq.app/Contents/Resources/app.asar/js/logseq-cli.js";
|
|
14
|
+
|
|
15
|
+
/** Config, plugins and plugin storage — NOT where graphs live. */
|
|
16
|
+
export function logseqDotDir(probe) {
|
|
17
|
+
return probe.env.LOGSEQ_DOTDIR || join(probe.home, ".logseq");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Graph root: `<root>/graphs/<name>/db.sqlite`. The app CLI calls it --root-dir. */
|
|
21
|
+
export function logseqRootDir(probe) {
|
|
22
|
+
return probe.env.LOGSEQ_ROOT_DIR || join(probe.home, "logseq");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The file the in-app plugin touches to say "the graph changed". */
|
|
26
|
+
export function signalFilePath(probe) {
|
|
27
|
+
return join(logseqDotDir(probe), "storages", PLUGIN_ID, SIGNAL_FILE);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What the user set in the plugin's own settings panel. Absent or half-written
|
|
32
|
+
* settings are not an error — they just mean "nothing configured yet".
|
|
33
|
+
*/
|
|
34
|
+
export function pluginSettings(probe) {
|
|
35
|
+
const path = join(logseqDotDir(probe), "settings", `${PLUGIN_ID}.json`);
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(probe.read(path));
|
|
38
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
39
|
+
} catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The launcher the app writes to its CLI install directory is a two-line
|
|
46
|
+
* wrapper it generates itself, and it stamps "logseq-cli-managed" into the
|
|
47
|
+
* file. On Windows that launcher is a .cmd, which Node cannot exec without a
|
|
48
|
+
* shell — but the two paths it names can be exec'd directly, which is all the
|
|
49
|
+
* wrapper does anyway. So: read it, do not run it.
|
|
50
|
+
* @returns {{command: string, argsPrefix: string[], env: Record<string,string>, how: string}|null}
|
|
51
|
+
*/
|
|
52
|
+
export function parseManagedShim(probe, path) {
|
|
53
|
+
let text;
|
|
54
|
+
try {
|
|
55
|
+
text = probe.read(path);
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (!text.includes("logseq-cli-managed")) return null;
|
|
60
|
+
const quoted = [...text.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
|
61
|
+
const exe = quoted[0];
|
|
62
|
+
const cliJs = quoted.find((q) => q.endsWith(".js"));
|
|
63
|
+
if (!exe || !cliJs) return null;
|
|
64
|
+
return {
|
|
65
|
+
command: exe,
|
|
66
|
+
argsPrefix: [cliJs],
|
|
67
|
+
env: { ELECTRON_RUN_AS_NODE: "1" },
|
|
68
|
+
how: `read from the launcher at ${path}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Every plausible way to reach the app's CLI, best first.
|
|
74
|
+
*
|
|
75
|
+
* The ranking matters more than the search: what the app itself installed is
|
|
76
|
+
* something it chose, on a platform we may never have run on. Anything we
|
|
77
|
+
* DEDUCE — a launcher we parse, a bundle path we hardcoded — ranks below it,
|
|
78
|
+
* because those are our assumptions rather than the app's own answer.
|
|
79
|
+
*
|
|
80
|
+
* @returns {{command: string, argsPrefix: string[], env: Record<string,string>, how: string}[]}
|
|
81
|
+
*/
|
|
82
|
+
export function appCliCandidates(probe) {
|
|
83
|
+
const win = probe.platform === "win32";
|
|
84
|
+
const out = [];
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
const add = (c) => {
|
|
87
|
+
if (c && !seen.has(c.command)) { seen.add(c.command); out.push(c); }
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// A launcher, wherever we find it: exec it directly when we can, and read
|
|
91
|
+
// the paths out of it when we cannot (Windows .cmd).
|
|
92
|
+
const launcher = (path, how) => {
|
|
93
|
+
if (!probe.exists(path)) return null;
|
|
94
|
+
if (/\.(cmd|bat)$/i.test(path)) {
|
|
95
|
+
const parsed = parseManagedShim(probe, path);
|
|
96
|
+
return parsed && { ...parsed, how: `${how}, read rather than run` };
|
|
97
|
+
}
|
|
98
|
+
return { command: path, argsPrefix: [], env: {}, how };
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// 1. On PATH — the app put it there for exactly this.
|
|
102
|
+
const sep = win ? ";" : ":";
|
|
103
|
+
for (const dir of (probe.env.PATH || "").split(sep).filter(Boolean)) {
|
|
104
|
+
for (const name of win ? ["logseq.exe", "logseq.cmd"] : ["logseq"]) {
|
|
105
|
+
add(launcher(join(dir, name), "found on PATH"));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 2. The app's default CLI install directory, even when it is not on PATH.
|
|
110
|
+
for (const name of win ? ["logseq.exe", "logseq.cmd"] : ["logseq"]) {
|
|
111
|
+
add(launcher(join(probe.home, ".local", "bin", name), "the launcher the app installs"));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 3. Last: a path we hardcoded. Verified before use, never assumed.
|
|
115
|
+
if (probe.platform === "darwin" && probe.exists(MAC_APP)) {
|
|
116
|
+
add({
|
|
117
|
+
command: MAC_APP,
|
|
118
|
+
argsPrefix: [MAC_APP_CLI_JS],
|
|
119
|
+
env: { ELECTRON_RUN_AS_NODE: "1" },
|
|
120
|
+
how: "the Logseq.app bundle",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The CLI to use. Pass `verify` — a function that actually tries a candidate —
|
|
129
|
+
* and the first one that works is returned, which beats any amount of guessing
|
|
130
|
+
* about where things live on an OS or version we have not run on.
|
|
131
|
+
* @param {(candidate: object) => boolean} [verify]
|
|
132
|
+
*/
|
|
133
|
+
export function findAppCli(probe, verify) {
|
|
134
|
+
const candidates = appCliCandidates(probe);
|
|
135
|
+
if (!verify) return candidates[0] ?? null;
|
|
136
|
+
for (const c of candidates) {
|
|
137
|
+
if (verify(c)) return c;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Which graph to sync.
|
|
144
|
+
* @returns {{name: string, how: string}|{candidates: string[]}|null}
|
|
145
|
+
* a name when it is unambiguous, a candidate list when the user must choose,
|
|
146
|
+
* null when there are no graphs at all.
|
|
147
|
+
*/
|
|
148
|
+
export function detectGraph(probe) {
|
|
149
|
+
const graphsDir = join(logseqRootDir(probe), "graphs");
|
|
150
|
+
const all = probe.listDir(graphsDir).filter((n) => !n.startsWith("."));
|
|
151
|
+
if (all.length === 0) return null;
|
|
152
|
+
|
|
153
|
+
// A db-worker lock says a worker exists, not that the app has the graph open:
|
|
154
|
+
// every `logseq graph export` starts one of its own and leaves the file
|
|
155
|
+
// behind. Only the desktop app stamps owner-source "electron", and that is
|
|
156
|
+
// the graph whose sqlite the direct exporter cannot read.
|
|
157
|
+
const open = all.filter((name) => {
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(probe.read(join(graphsDir, name, "db-worker.lock")))["owner-source"] === "electron";
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
if (open.length === 1) return { name: open[0], how: "open in the app", graphs: all };
|
|
165
|
+
if (all.length === 1) return { name: all[0], how: "the only graph", graphs: all };
|
|
166
|
+
|
|
167
|
+
return { candidates: all, graphs: all };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The same question as detectGraph, asked of the CLI instead of the filesystem.
|
|
172
|
+
* `graph list` and `server list` are the app's own answers, so this holds on
|
|
173
|
+
* any OS and for a graph root nobody left in the default place — and
|
|
174
|
+
* owner-source comes from the app rather than from reading its lock files.
|
|
175
|
+
*
|
|
176
|
+
* @param {(args: string[]) => string} runCli returns stdout, or throws
|
|
177
|
+
* @returns {{name: string, how: string, graphs: string[], rootDir: string|null}
|
|
178
|
+
* |{candidates: string[], graphs: string[], rootDir: string|null}
|
|
179
|
+
* |null} null means "could not ask" — the caller should fall back.
|
|
180
|
+
*/
|
|
181
|
+
export function detectGraphViaCli(runCli) {
|
|
182
|
+
let graphs;
|
|
183
|
+
try {
|
|
184
|
+
graphs = JSON.parse(runCli(["graph", "list", "-o", "json"]))?.data?.graphs;
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
if (!Array.isArray(graphs) || graphs.length === 0) return null;
|
|
189
|
+
|
|
190
|
+
// Servers are a bonus, not a requirement: with none running we still know
|
|
191
|
+
// the graphs, we just cannot tell which one the app has open.
|
|
192
|
+
let open = null;
|
|
193
|
+
let rootDir = null;
|
|
194
|
+
try {
|
|
195
|
+
const servers = JSON.parse(runCli(["server", "list", "-o", "json"]))?.data?.servers ?? [];
|
|
196
|
+
rootDir = servers[0]?.["root-dir"] ?? null;
|
|
197
|
+
const appOwned = servers.filter((s) => s?.["owner-source"] === "electron");
|
|
198
|
+
if (appOwned.length === 1) open = appOwned[0].graph;
|
|
199
|
+
} catch {}
|
|
200
|
+
|
|
201
|
+
if (open && graphs.includes(open)) return { name: open, how: "open in the app", graphs, rootDir };
|
|
202
|
+
if (graphs.length === 1) return { name: graphs[0], how: "the only graph", graphs, rootDir };
|
|
203
|
+
return { candidates: graphs, graphs, rootDir };
|
|
204
|
+
}
|
package/core/src/sync-engine.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
renameSync,
|
|
13
13
|
existsSync,
|
|
14
14
|
} from "node:fs";
|
|
15
|
-
import { join, dirname, relative, resolve, sep } from "node:path";
|
|
15
|
+
import { join, dirname, relative, resolve, sep, isAbsolute } from "node:path";
|
|
16
16
|
import { execFileSync } from "node:child_process";
|
|
17
17
|
import { randomUUID } from "node:crypto";
|
|
18
18
|
import { ednToGemlFiles, gemlFilesToEdn } from "./mapping.mjs";
|
|
@@ -297,8 +297,38 @@ export async function syncEdnToDisk(ednText, targetDir, opts = {}) {
|
|
|
297
297
|
|
|
298
298
|
const diffResult = writeGemlFilesToDisk(gemlFiles, targetDir, opts);
|
|
299
299
|
|
|
300
|
+
// A parallel Markdown tree, for people and tools that read Markdown and
|
|
301
|
+
// nothing else. Deliberately lossy and deliberately separate: the GEML tree
|
|
302
|
+
// stays the one that round-trips. The converter is injected, so this module
|
|
303
|
+
// keeps its single dependency.
|
|
304
|
+
const markdownWritten = [];
|
|
305
|
+
if (opts.markdownDir && typeof opts.gemlToMd === "function") {
|
|
306
|
+
for (const [rel, content] of gemlFiles) {
|
|
307
|
+
const mdRel = rel.replace(/\.geml$/, ".md");
|
|
308
|
+
const full = join(opts.markdownDir, mdRel);
|
|
309
|
+
let md;
|
|
310
|
+
try {
|
|
311
|
+
md = normalizeEol(opts.gemlToMd(content));
|
|
312
|
+
} catch {
|
|
313
|
+
continue; // one unconvertible document must not fail the sync
|
|
314
|
+
}
|
|
315
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
316
|
+
if (!existsSync(full) || readFileSync(full, "utf8") !== md) {
|
|
317
|
+
atomicWriteFileSync(full, md);
|
|
318
|
+
markdownWritten.push(mdRel);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
300
323
|
let gitResult = null;
|
|
301
324
|
const pathsModified = [...diffResult.written, ...diffResult.deleted];
|
|
325
|
+
for (const rel of markdownWritten) {
|
|
326
|
+
const abs = join(opts.markdownDir, rel);
|
|
327
|
+
const insideVault = relative(targetDir, abs);
|
|
328
|
+
if (insideVault && !insideVault.startsWith("..") && !isAbsolute(insideVault)) {
|
|
329
|
+
pathsModified.push(insideVault);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
302
332
|
|
|
303
333
|
if (opts.autoCommit && pathsModified.length > 0) {
|
|
304
334
|
const msg = opts.commitMessage || `logseq-geml: synced ${diffResult.written.length} modified, ${diffResult.deleted.length} deleted`;
|
|
@@ -307,6 +337,7 @@ export async function syncEdnToDisk(ednText, targetDir, opts = {}) {
|
|
|
307
337
|
|
|
308
338
|
return {
|
|
309
339
|
...diffResult,
|
|
340
|
+
markdownWritten,
|
|
310
341
|
gitResult,
|
|
311
342
|
};
|
|
312
343
|
}
|
package/docs/how-it-works.svg
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
<!-- watcher box -->
|
|
27
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">
|
|
28
|
+
<text x="612" y="62" text-anchor="middle" font-size="15" font-weight="700" fill="#57606a">logseq-sync watcher (CLI)</text>
|
|
29
29
|
<text x="612" y="86" text-anchor="middle" font-size="11.5" fill="#57606a">export via official @logseq/cli</text>
|
|
30
30
|
<text x="612" y="103" text-anchor="middle" font-size="11.5" fill="#57606a">writes only files that changed</text>
|
|
31
31
|
<text x="612" y="120" text-anchor="middle" font-size="11.5" fill="#57606a">git commit scoped to the vault</text>
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,53 +1,54 @@
|
|
|
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
|
-
"
|
|
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 watcher/test/cli-sync.test.mjs && node watcher/test/signal-sync.test.mjs && node plugin/test/core.test.mjs",
|
|
47
|
-
"sync": "node watcher/bin/
|
|
48
|
-
"build:plugin": "node plugin/build.mjs"
|
|
49
|
-
},
|
|
50
|
-
"dependencies": {
|
|
51
|
-
"
|
|
52
|
-
|
|
53
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@geml/logseq-sync",
|
|
3
|
+
"version": "2.0.3",
|
|
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.6",
|
|
52
|
+
"edn-data": "^1.2.2"
|
|
53
|
+
}
|
|
54
|
+
}
|