@geml/logseq-sync 2.0.0 → 2.0.2

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.
@@ -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
+ }
@@ -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
  }
Binary file
Binary file
package/package.json CHANGED
@@ -1,53 +1,54 @@
1
- {
2
- "name": "@geml/logseq-sync",
3
- "version": "2.0.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
- "geml-sync": "watcher/bin/geml-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 watcher/test/cli-sync.test.mjs && node watcher/test/signal-sync.test.mjs && node plugin/test/core.test.mjs",
47
- "sync": "node watcher/bin/geml-sync.mjs",
48
- "build:plugin": "node plugin/build.mjs"
49
- },
50
- "dependencies": {
51
- "edn-data": "^1.2.2"
52
- }
53
- }
1
+ {
2
+ "name": "@geml/logseq-sync",
3
+ "version": "2.0.2",
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
+ "geml-sync": "watcher/bin/geml-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/geml-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
+ }