@geml/logseq-sync 2.0.9 → 2.1.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/README.md CHANGED
@@ -1,377 +1,396 @@
1
- # Sync Vault with GEML
2
-
3
- Your Logseq DB graph as **continuously synced plain-text files** — pages and
4
- journals back in readable files and folders, the way OG vaults felt, kept in
5
- step with the database. And, when you want it, back again.
6
-
7
- ![How it works](docs/how-it-works.svg)
8
-
9
- Edit a block; seconds later the file on disk has caught up, and the toolbar says
10
- so:
11
-
12
- ![The toolbar reports the last sync](docs/screenshot-toolbar.png)
13
-
14
- Two settings, and only the first one usually needs touching:
15
-
16
- ![The plugin's settings](docs/screenshot-settings.png)
17
-
18
- ## What you get
19
-
20
- - đŸ“Ļ **A plain-text copy that stays yours** — every page a readable file, not a
21
- database dump, in a folder you chose
22
- - 🔁 **Continuous, not one-shot** — edit in Logseq, and seconds later the file
23
- on disk has caught up; with `--two-way`, edit the file and the graph
24
- catches up the same way
25
- - â†Šī¸ **A way back** — `logseq-sync restore` imports the vault into a graph,
26
- merging by block uuid. Files you can read are worth more when they are also
27
- files you can return
28
- - đŸŒŋ **Git if you want it** — point the vault at a repository and every sync is
29
- a clean commit with a line-by-line diff. Point it at a plain folder, or one
30
- your backup tool already watches, and nothing git-shaped appears
31
-
32
- A vault is not a mirror by default: pages you delete in Logseq are **kept** on
33
- disk and reported, because a plain folder has no history to recover them from.
34
- `--mirror` is how you ask for an exact copy instead.
35
-
36
- Logseq 2.0 ships both ends of a trade-off: `logseq export` gives Markdown
37
- (readable, lossy) and `logseq export-edn` gives EDN (lossless, not something a
38
- person edits). The vault's format, [GEML](https://github.com/geml-spec/geml),
39
- is the point between: **as readable as the Markdown export, as lossless as the
40
- EDN one** — and addressable, so external tools and agents can edit one block
41
- of a graph instead of round-tripping all of it.
42
-
43
- The tree is laid out the way an OG vault is — the thing a file-version user
44
- recognizes as "my graph, as files again":
45
-
46
- ```
47
- {:pages-and-blocks [...]} ontology.geml :properties/:classes, verbatim EDN
48
- ⇄ graph.geml page ORDER (an addressable data block,
49
- so filenames need no numeric prefixes)
50
- journals/2025_02_20.geml journal pages, OG date names
51
- pages/<name>.geml one per page:
52
- block title → `=== text` body
53
- block uuid → `{#uuid}` ← geml get/set address
54
- outline tree → flat blocks with `.level-N`
55
- everything else rides along in `code {lang=edn}`
56
- ```
57
-
58
- (Journal pages export as pages carrying `{:build/journal <yyyymmdd>}`, and the
59
- mapping routes them into `journals/` under their OG date name — verified on a
60
- live 2.0.1 graph, schema 65.33.)
61
-
62
- ## How it works — two halves, one honest boundary
63
-
64
- A Logseq 2.0 plugin runs in a sandboxed iframe: no arbitrary-path filesystem,
65
- no git, no shell (verified against the 2.0.1 app bundle). So the in-app plugin
66
- (`plugin/`) does the only two things only it can do:
67
-
68
- - **hear** the graph change (`logseq.DB.onChanged`, debounced) and write a
69
- dirty-marker file through the plugin storage API;
70
- - **show** the last sync result in the toolbar (`⇄`) and command palette.
71
-
72
- Everything with side effects lives in the **watcher** (`watcher/bin/logseq-sync.mjs`),
73
- built on Logseq's own EDN export. It reacts to the marker file
74
- immediately (interval polling stays on as a fallback), writes only the files
75
- that actually changed — so `git diff` is never noise — commits with a pathspec
76
- scoped strictly to the vault, and reports back for the toolbar to display.
77
- The two halves meet in the plugin's own storage directory
78
- (`<dotdir>/storages/logseq-plugin-sync-vault-with-geml/`), the one disk location both can
79
- reach. A file as the bridge beats a local HTTP API: no port, no server, no
80
- CORS.
81
-
82
- Real output, real DB graph (exported with the official CLI, validated by
83
- `logseq validate`):
84
-
85
- ```text
86
- $ logseq-sync geml-spike ~/vault-demo --once --git-commit --no-signal
87
- Sync Vault with GEML: graph "geml-spike" ➔ ~/vault-demo
88
- export via @logseq/cli, opening the graph file directly — close the graph in Logseq first
89
- git auto-commit on, scoped to the vault
90
- [10:15:28] Synced: 8 written, 0 unchanged.
91
- Git: [master (root-commit) 2854063] logseq-geml: sync graph "geml-spike"
92
- 9 files changed, 142 insertions(+)
93
- create mode 100644 graph.geml
94
- create mode 100644 pages/contents.geml
95
- ...
96
-
97
- $ logseq-sync geml-spike ~/vault-demo --once --git-commit --no-signal # run again
98
- [10:15:31] Graph is up-to-date (0 written, 8 unchanged).
99
- ```
100
-
101
- ## Setup
102
-
103
- **1. Install the plugin.** From the marketplace, or download the zip from the
104
- [latest release](https://github.com/geml-spec/logseq-plugin-sync-vault-with-geml/releases/latest)
105
- and load it — the release carries the built plugin, so there is nothing to
106
- compile.
107
-
108
- **2. Set the vault folder** in Logseq: Settings → Plugins → *Sync Vault with
109
- GEML* → **Vault folder**. Any folder you like — `~/logseq-vault`, a directory
110
- inside a repository you already keep, one your backup tool already watches. It
111
- is created if it does not exist, `~` means your home directory, and `restore`
112
- reads the vault back from the same place. There is deliberately **no default**:
113
- left empty, `logseq-sync` asks you for a folder rather than picking one for you.
114
-
115
- That is the folder the files are written **into**; the graph they come **from**
116
- is detected, and you do not name it.
117
-
118
- **3. Run the watcher:**
119
-
120
- ```sh
121
- npx @geml/logseq-sync
122
- ```
123
-
124
- That is the setup. With no arguments the watcher works out the rest: the CLI
125
- that ships inside the Logseq app, the graph the app currently has open, the
126
- plugin's signal file, and the vault path you just set. It makes the vault a
127
- git repository if it is not one already, syncs, and keeps watching. Edit a
128
- block in Logseq → the plugin signals → the watcher syncs → the toolbar `⇄`
129
- shows `Sync Vault with GEML: last sync at â€Ļ — 1 written, 7 unchanged.`
130
-
131
- Not sure it is wired up? **`npx @geml/logseq-sync doctor`** prints what it
132
- found and what is missing, and exits non-zero when the setup cannot sync:
133
-
134
- ```text
135
- ok Logseq dotdir /Users/you/.logseq
136
- ok plugin /Users/you/.logseq/storages/logseq-plugin-sync-vault-with-geml
137
- ok app CLI /Users/you/.local/bin/logseq (found on PATH)
138
- ok graph Demo (open in the app)
139
- MISS vault unset — Settings → Plugins → Sync Vault with GEML → "Vault folder"
140
- ok git identity configured
141
- ok bridge /Users/you/.logseq/storages/.../geml-sync-dirty.json
142
- ```
143
-
144
- ### When you want to say it yourself
145
-
146
- | | |
147
- |---|---|
148
- | `logseq-sync <vault-dir>` | vault here instead of in the plugin settings |
149
- | `logseq-sync <graph> <vault-dir>` | both explicitly |
150
- | `--graph <name>` | pick the graph — needed when several are open |
151
- | `--once` | sync once and exit, instead of watching |
152
- | `--git-commit` | commit, creating the vault repository if there is none |
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) |
155
- | `--mirror` | delete vault files for pages removed from the graph |
156
- | `--markdown <dir>` | also write the graph there as an OG (file-version) graph the old app opens — lossy, one-way |
157
- | `--overwrite-unmanaged` | overwrite files that were already there before the sync owned them (default: hold and name them) |
158
- | `--interval <seconds>` | heartbeat between signals (default 10) |
159
- | `--app-cli <path>` | a Logseq CLI the search did not find |
160
- | `--signal <file>` / `--no-signal` | the plugin bridge, or none |
161
-
162
- ### Going back: `logseq-sync restore`
163
-
164
- ```sh
165
- logseq-sync restore # rehearse: says what it would import, writes nothing
166
- logseq-sync restore --yes # take a Logseq backup, then import the vault
167
- ```
168
-
169
- The vault imports into the graph by block uuid, so an edit lands in place
170
- rather than duplicating. This is the one direction that writes into your notes,
171
- so it rehearses unless you pass `--yes`, and `--yes` takes the app's own graph
172
- backup first (`--no-backup` opts out, and then you are on your own).
173
-
174
- ### The exporter, and why the app's own CLI
175
-
176
- While Logseq has a graph open its db-worker holds an **exclusive lock** on that
177
- graph's `db.sqlite`, so an exporter that opens the file directly dies with
178
- `database is locked` — which is every export while you are actually working.
179
- The CLI inside the desktop app does not open the file, it asks the running app,
180
- so it exports mid-edit. That is why the watcher looks for it first: on PATH, at
181
- `~/.local/bin/logseq`, then the app bundle itself.
182
-
183
- `--no-app-cli` falls back to the separate [`@logseq/cli`](https://www.npmjs.com/package/@logseq/cli)
184
- npm package, which opens the graph file directly. It is only useful against a
185
- graph the app does **not** have open, and on Node 24 it needs a
186
- `better-sqlite3` override to install at all:
187
-
188
- ```sh
189
- mkdir logseq-cli && cd logseq-cli && npm init -y
190
- npm pkg set overrides.better-sqlite3=12.11.1
191
- npm i @logseq/cli
192
- # then: LOGSEQ_CLI_DIR=$PWD logseq-sync --no-app-cli â€Ļ
193
- ```
194
-
195
- `--api-server-token` (or `LOGSEQ_API_SERVER_TOKEN`) routes that fallback
196
- through the app's HTTP API server rather than the file — but `@logseq/cli`
197
- 0.4.3 hardcodes `http://127.0.0.1:12315` and Logseq 2.0.1 does not listen
198
- there, so on 2.0.1 this path goes nowhere. Prefer the app CLI.
199
-
200
- **Settings**: *Vault folder* — where the files are written, and where `restore`
201
- reads them back from. *Debounce (seconds)* — quiet
202
- period after the last change before the watcher is signalled (default 5; syncs
203
- feed git commits, so this is deliberately calmer than UI-style debounce).
204
-
205
- ### Editing the vault from outside
206
-
207
- The vault is ordinary text, and that is the point: agents, scripts and plain
208
- `sed` all work on it, and none of them needs to know Logseq exists. With
209
- `--two-way` running, an edit imports on the next cycle; without it, run
210
- `logseq-sync restore` when you are ready.
211
-
212
- **An agent (Claude, or anything speaking MCP)** gets addressed, validated
213
- block edits from the [`geml` MCP server](https://github.com/geml-spec/geml):
214
-
215
- ```sh
216
- npm i -g @geml/geml
217
- geml mcp --root <your-vault-dir> --no-history
218
- ```
219
-
220
- `--no-history` matters here: git is this vault's history, and without the flag
221
- every MCP write also saves a `.gemlhistory` sidecar revision beside the file.
222
- (If you want those too, drop the flag — the sync ignores sidecars either way
223
- and never commits them.)
224
-
225
- **A one-liner** reads or edits one block by its address — every block carries
226
- its uuid:
227
-
228
- ```sh
229
- geml find "that phrase" <vault-dir> # → pages/foo.geml #<uuid>
230
- geml get <vault-dir>/pages/foo.geml '#<uuid>'
231
- printf 'new text' | geml set <vault-dir>/pages/foo.geml '#<uuid>' --in - -o <same-file>
232
- ```
233
-
234
- **Bulk refactoring** is whatever your shell already does — the result is
235
- re-imported by uuid, so identity survives the edit:
236
-
237
- ```sh
238
- grep -rl "old-tag" <vault-dir>/pages | xargs sed -i 's/old-tag/new-tag/g'
239
- logseq-sync restore <vault-dir> --yes # or let --two-way pick it up
240
- ```
241
-
242
- A check after a bulk edit is cheap insurance — it names a mangled block, and a
243
- reference that now goes nowhere, before the import carries either into the
244
- graph:
245
-
246
- ```sh
247
- geml check <vault-dir>/pages/foo.geml --root <vault-dir>
248
- ```
249
-
250
- `--root` is what lets a reference into another page resolve: block refs are
251
- translated on the way out, so `[[<uuid>]]` in the graph becomes GEML's checked
252
- `[[#uuid]]` (same page) or `[[../pages/other.geml#uuid]]` (another one), and
253
- the translation reverses exactly on the way back.
254
-
255
- ## Honesty corner
256
-
257
- - The **default** continuous direction is graph → files; going back is a
258
- deliberate command (`restore`). `--two-way` makes the return trip continuous
259
- too — every cycle imports what changed in the vault — under three rules that
260
- say what it does NOT pretend to solve: a file changed on **both** sides
261
- since the last sync is a conflict, held exactly as you left it (not
262
- imported, not overwritten, named in the toolbar status until you merge it);
263
- deletions are **never** imported; and a graph backup is taken before the
264
- first import and every tenth after. The sync tells its own writes from
265
- yours by content hash, so nothing echoes.
266
- - **Files the sync did not write are never touched** — not deleted, and not
267
- overwritten either. A manifest per tree records what the sync wrote; a file
268
- on disk that no manifest claims belongs to whoever put it there, so it is
269
- held and named instead of replaced, and `--mirror` only ever removes files
270
- from that list. This is what makes it safe to point a vault (or
271
- `--markdown`) at a graph you already have: your pages survive the first
272
- sync. A file already byte-identical to what the sync would write is adopted
273
- rather than held — there is nothing of yours to lose. `--overwrite-unmanaged`
274
- is how you say you meant it.
275
- - **The app's lock is the thing to know about.** A running Logseq holds
276
- `db.sqlite` exclusively, so the `@logseq/cli` export only works with the app
277
- closed (or on a graph it does not have open). Continuous sync therefore runs
278
- through the desktop app's own CLI (`--app-cli`), which asks the running app
279
- instead of touching the file. Verified on 2.0.1: same 9 documents as the
280
- offline export, byte-identical except three keys of export metadata.
281
- - **The Markdown tree is an OG graph, and it is a copy.** `--markdown` writes
282
- Logseq's own file-version dialect — one bullet per block, `id::` for
283
- identity, `((uuid))` for block refs — so the directory **opens in the file
284
- version of the app**. It is lossy and one-way: typed properties, tags,
285
- tables and data blocks have no OG shape and do not survive, the GEML tree
286
- stays the one that round-trips, and `restore` never reads the Markdown.
287
- Generic GEML-to-Markdown is `geml <file> --to md`, which belongs to the
288
- parser; the only reason this integration writes Markdown is Logseq.
289
- - **Restore merges, it does not replace.** An import lands by uuid over
290
- whatever the graph currently holds; it will not remove pages the vault no
291
- longer has. Take the backup.
292
- - **A graph name you mistype is created, not rejected.** `logseq graph export
293
- --graph <name>` silently makes a new empty graph rather than failing, and
294
- syncing that emptiness would wipe the vault's synced files. The watcher
295
- refuses any graph name it cannot see under `<root>/graphs` first.
296
- - **A commit that fails is printed, not swallowed.** `git` with no configured
297
- author (or `user.useConfigOnly`) writes the files and commits nothing;
298
- `doctor` calls that out up front, and a sync that could not commit says
299
- `Git: NOT COMMITTED — â€Ļ` rather than just `Synced`.
300
- - **2.0 renamed the export we read.** `:export-type :graph` now means a datoms
301
- dump; the `{:pages-and-blocks ...}` shape this converter reads is
302
- `:graph-human`. The watcher asks for `:graph-human` explicitly.
303
- - The watcher half is tested end-to-end in CI (a planted fake CLI exports
304
- fixture EDN, so the signal → re-sync → status round trip runs with no Logseq
305
- installed). The in-app half is verified against the 2.0.1 runtime — the
306
- plugin API surface, `hook:db:changed`, the storage-file bridge — and its
307
- SDK is `@logseq/libs` 0.3.x (the `next` tag). If anything misbehaves in
308
- your setup, an issue with your Logseq version is gold.
309
-
310
- ## Proven on a live DB graph, judged by Logseq's own validator
311
-
312
- `npm test` proves, on fixtures lifted from Logseq's own `deps/db` export tests:
313
-
314
- 1. **EDN → GEML → EDN is a structural identity** (EDN map/set semantics).
315
- 2. Every generated document parses as GEML with **zero error diagnostics**.
316
- 3. A block Logseq considers addressable (exported uuid) is **addressable in
317
- GEML by the same id**.
318
- 4. **Editing one block's text changes exactly that block** in the EDN — no
319
- collateral change anywhere in the graph.
320
-
321
- And `bin/live-roundtrip.mjs` has confirmed all four against a real DB graph
322
- (2026-08-20, schema 65.22): export → 6 clean documents → identity; then with
323
- `--edit`, a `geml set` on one block imported back with `logseq import-edn`,
324
- **`logseq validate`: Valid!**, and the re-export showed the edit landed **in
325
- place by uuid, exactly once — whole-graph re-import merges, it does not
326
- duplicate**.
327
-
328
- The design and the reasoning live in the
329
- [GEML monorepo](https://github.com/geml-spec/geml)
330
- (`docs/design/specs/2026-08-20-logseq-integration-scoping.md`); the community
331
- threads are
332
- [logseq/logseq#13086](https://github.com/logseq/logseq/discussions/13086) and
333
- [the forum post](https://discuss.logseq.com/t/35193).
334
-
335
- ## Development
336
-
337
- ```
338
- core/ converter (mapping.mjs), sync engine, bridge.mjs (the signal/status file contract)
339
- watcher/ the logseq-sync CLI and its end-to-end tests — published to npm as @geml/logseq-sync
340
- plugin/ the in-app half (this package.json is the Logseq plugin manifest)
341
- ```
342
-
343
- Source of truth is
344
- [`integrations/logseq/`](https://github.com/geml-spec/geml/tree/main/integrations/logseq)
345
- in the GEML monorepo; this repository mirrors it for the marketplace and
346
- carries the releases. Please open issues here, and PRs against the monorepo.
347
-
348
- The converter is two pure functions in `core/src/mapping.mjs` —
349
- `ednToGemlFiles(ednText)` and `gemlFilesToEdn(files, lib)` — with the reference
350
- parser injected. The tests import the parser's build:
351
-
352
- ```sh
353
- cd geml-parser && npm install && npm run build && cd ../integrations/logseq
354
- npm install
355
- npm test
356
- ```
357
-
358
- Live-stage demos (need `@logseq/cli` via `LOGSEQ_CLI_DIR` — see "The exporter" above):
359
-
360
- ```sh
361
- node watcher/bin/create-graph.mjs my-graph # create a DB graph WITHOUT the desktop app
362
- node watcher/bin/live-roundtrip.mjs my-graph # read-only: export → GEML → back → compare
363
- node watcher/bin/live-roundtrip.mjs my-graph --edit # + geml set → import-edn → logseq validate
364
- ```
365
-
366
- Versioning: the MAJOR version tracks the Logseq major it targets — this is
367
- 2.x because it speaks Logseq 2.x (DB graphs) and nothing older. Minor/patch
368
- are this package's own.
369
-
370
- ## Next
371
-
372
- - Property readability: scalar `:build/properties` as GEML attributes instead
373
- of the `.block-meta` EDN ride-along (NAME rules permitting).
374
- - **Write-back**: wiring `syncDiskToEdn` to the CLI so the vault is
375
- two-way — edit the file, the graph follows.
376
-
377
- MIT Š GEML contributors
1
+ # Sync Vault with GEML
2
+
3
+ Your Logseq DB graph as **continuously synced plain-text files** — pages and
4
+ journals back in readable files and folders, the way OG vaults felt, kept in
5
+ step with the database. And, when you want it, back again.
6
+
7
+ ![How it works](docs/how-it-works.svg)
8
+
9
+ Edit a block; seconds later the file on disk has caught up, and the toolbar says
10
+ so:
11
+
12
+ ![The toolbar reports the last sync](docs/screenshot-toolbar.png)
13
+
14
+ Two settings, and only the first one usually needs touching:
15
+
16
+ ![The plugin's settings](docs/screenshot-settings.png)
17
+
18
+ ## What you get
19
+
20
+ - đŸ“Ļ **A plain-text copy that stays yours** — every page a readable file, not a
21
+ database dump, in a folder you chose
22
+ - 🔁 **Continuous, not one-shot** — edit in Logseq, and seconds later the file
23
+ on disk has caught up; with `--two-way`, edit the file and the graph
24
+ catches up the same way
25
+ - â†Šī¸ **A way back** — `logseq-sync restore` imports the vault into a graph,
26
+ merging by block uuid. Files you can read are worth more when they are also
27
+ files you can return
28
+ - đŸŒŋ **Git if you want it** — point the vault at a repository and every sync is
29
+ a clean commit with a line-by-line diff. Point it at a plain folder, or one
30
+ your backup tool already watches, and nothing git-shaped appears
31
+
32
+ A vault is not a mirror by default: pages you delete in Logseq are **kept** on
33
+ disk and reported, because a plain folder has no history to recover them from.
34
+ `--mirror` is how you ask for an exact copy instead.
35
+
36
+ Logseq 2.0 ships both ends of a trade-off: `logseq export` gives Markdown
37
+ (readable, lossy) and `logseq export-edn` gives EDN (lossless, not something a
38
+ person edits). The vault's format, [GEML](https://github.com/geml-spec/geml),
39
+ is the point between: **as readable as the Markdown export, as lossless as the
40
+ EDN one** — and addressable, so external tools and agents can edit one block
41
+ of a graph instead of round-tripping all of it.
42
+
43
+ **The vault root is a graph you can open.** Markdown pages sit at the top, in
44
+ Logseq's own file-version dialect; the GEML tree — the source of truth — sits in
45
+ a dot directory beneath them, which Logseq's file-graph indexer walks past.
46
+
47
+ ```
48
+ <vault>/
49
+ pages/<name>.md journals/2025_02_20.md OG Markdown. Open this folder in
50
+ Logseq (file version). Lossy, one-way.
51
+ .logseq-sync-vault-with-geml/ The source of truth. `restore` and
52
+ `--two-way` read only this.
53
+ ontology.geml :properties/:classes, verbatim EDN
54
+ graph.geml page ORDER (an addressable data block, so
55
+ filenames need no numeric prefixes)
56
+ journals/2025_02_20.geml journal pages, OG date names
57
+ pages/<name>.geml one per page:
58
+ block title → `=== text` body
59
+ block uuid → `{#uuid}` ← geml get/set address
60
+ outline tree → flat blocks with `.level-N`
61
+ everything else rides along in `code {lang=edn}`
62
+ .geml-manifest.json what this tool last wrote, so a stranger's edit
63
+ is distinguishable from its own echo
64
+ ```
65
+
66
+ The GEML tree is laid out the way an OG vault is — the thing a file-version user
67
+ recognizes as "my graph, as files again".
68
+
69
+ **A folder that looks like an ordinary Logseq graph invites editing, and a
70
+ Markdown edit does not reach the graph** — the mapping is lossy and one-way. The
71
+ answer is not to hide the Markdown but to refuse to overwrite an edit silently:
72
+ a page you changed is held, named in the run's output, and left exactly as you
73
+ wrote it unless you pass `--overwrite-unmanaged`.
74
+
75
+ (Journal pages export as pages carrying `{:build/journal <yyyymmdd>}`, and the
76
+ mapping routes them into `journals/` under their OG date name — verified on a
77
+ live 2.0.1 graph, schema 65.33.)
78
+
79
+ ## How it works — two halves, one honest boundary
80
+
81
+ A Logseq 2.0 plugin runs in a sandboxed iframe: no arbitrary-path filesystem,
82
+ no git, no shell (verified against the 2.0.1 app bundle). So the in-app plugin
83
+ (`plugin/`) does the only two things only it can do:
84
+
85
+ - **hear** the graph change (`logseq.DB.onChanged`, debounced) and write a
86
+ dirty-marker file through the plugin storage API;
87
+ - **show** the last sync result in the toolbar (`⇄`) and command palette.
88
+
89
+ Everything with side effects lives in the **watcher** (`watcher/bin/logseq-sync.mjs`),
90
+ built on Logseq's own EDN export. It reacts to the marker file
91
+ immediately (interval polling stays on as a fallback), writes only the files
92
+ that actually changed — so `git diff` is never noise — commits with a pathspec
93
+ scoped strictly to the vault, and reports back for the toolbar to display.
94
+ The two halves meet in the plugin's own storage directory
95
+ (`<dotdir>/storages/logseq-plugin-sync-vault-with-geml/`), the one disk location both can
96
+ reach. A file as the bridge beats a local HTTP API: no port, no server, no
97
+ CORS.
98
+
99
+ Real output, real DB graph (exported with the official CLI, validated by
100
+ `logseq validate`):
101
+
102
+ ```text
103
+ $ logseq-sync geml-spike ~/vault-demo --once --git-commit --no-signal
104
+ Sync Vault with GEML: graph "geml-spike" ➔ ~/vault-demo
105
+ export via @logseq/cli, opening the graph file directly — close the graph in Logseq first
106
+ git auto-commit on, scoped to the vault
107
+ [10:15:28] Synced: 8 written, 0 unchanged.
108
+ Git: [master (root-commit) 2854063] logseq-geml: sync graph "geml-spike"
109
+ 9 files changed, 142 insertions(+)
110
+ create mode 100644 pages/contents.md
111
+ create mode 100644 .logseq-sync-vault-with-geml/graph.geml
112
+ create mode 100644 .logseq-sync-vault-with-geml/pages/contents.geml
113
+ ...
114
+
115
+ $ logseq-sync geml-spike ~/vault-demo --once --git-commit --no-signal # run again
116
+ [10:15:31] Graph is up-to-date (0 written, 8 unchanged).
117
+ ```
118
+
119
+ ## Setup
120
+
121
+ **1. Install the plugin.** From the marketplace, or download the zip from the
122
+ [latest release](https://github.com/geml-spec/logseq-plugin-sync-vault-with-geml/releases/latest)
123
+ and load it — the release carries the built plugin, so there is nothing to
124
+ compile.
125
+
126
+ **2. Set the vault folder** in Logseq: Settings → Plugins → *Sync Vault with
127
+ GEML* → **Vault folder**. Any folder you like — `~/logseq-vault`, a directory
128
+ inside a repository you already keep, one your backup tool already watches. It
129
+ is created if it does not exist, `~` means your home directory, and `restore`
130
+ reads the vault back from the same place. There is deliberately **no default**:
131
+ left empty, `logseq-sync` asks you for a folder rather than picking one for you.
132
+
133
+ That is the folder the files are written **into**; the graph they come **from**
134
+ is detected, and you do not name it.
135
+
136
+ **3. Run the watcher:**
137
+
138
+ ```sh
139
+ npx @geml/logseq-sync
140
+ ```
141
+
142
+ That is the setup. With no arguments the watcher works out the rest: the CLI
143
+ that ships inside the Logseq app, the graph the app currently has open, the
144
+ plugin's signal file, and the vault path you just set. It makes the vault a
145
+ git repository if it is not one already, syncs, and keeps watching. Edit a
146
+ block in Logseq → the plugin signals → the watcher syncs → the toolbar `⇄`
147
+ shows `Sync Vault with GEML: last sync at â€Ļ — 1 written, 7 unchanged.`
148
+
149
+ Not sure it is wired up? **`npx @geml/logseq-sync doctor`** prints what it
150
+ found and what is missing, and exits non-zero when the setup cannot sync:
151
+
152
+ ```text
153
+ ok Logseq dotdir /Users/you/.logseq
154
+ ok plugin /Users/you/.logseq/storages/logseq-plugin-sync-vault-with-geml
155
+ ok app CLI /Users/you/.local/bin/logseq (found on PATH)
156
+ ok graph Demo (open in the app)
157
+ MISS vault unset — Settings → Plugins → Sync Vault with GEML → "Vault folder"
158
+ ok git identity configured
159
+ ok bridge /Users/you/.logseq/storages/.../geml-sync-dirty.json
160
+ ```
161
+
162
+ ### When you want to say it yourself
163
+
164
+ | | |
165
+ |---|---|
166
+ | `logseq-sync <vault-dir>` | vault here instead of in the plugin settings |
167
+ | `logseq-sync <graph> <vault-dir>` | both explicitly |
168
+ | `--graph <name>` | pick the graph — needed when several are open |
169
+ | `--once` | sync once and exit, instead of watching |
170
+ | `--git-commit` | commit, creating the vault repository if there is none |
171
+ | `--no-git-commit` | never touch git |
172
+ | `--two-way` | also import vault edits back, every cycle — conflicts held, deletions never imported (needs the app CLI) |
173
+ | `--mirror` | delete vault files for pages removed from the graph |
174
+ | `--markdown <dir>` | write the OG Markdown graph somewhere ELSE than the vault root — lossy, one-way |
175
+ | `--no-markdown` | write no Markdown at all; GEML tree only |
176
+ | `--overwrite-unmanaged` | overwrite files that were already there before the sync owned them (default: hold and name them) |
177
+ | `--interval <seconds>` | heartbeat between signals (default 10) |
178
+ | `--app-cli <path>` | a Logseq CLI the search did not find |
179
+ | `--signal <file>` / `--no-signal` | the plugin bridge, or none |
180
+
181
+ ### Going back: `logseq-sync restore`
182
+
183
+ ```sh
184
+ logseq-sync restore # rehearse: says what it would import, writes nothing
185
+ logseq-sync restore --yes # take a Logseq backup, then import the vault
186
+ ```
187
+
188
+ The vault imports into the graph by block uuid, so an edit lands in place
189
+ rather than duplicating. This is the one direction that writes into your notes,
190
+ so it rehearses unless you pass `--yes`, and `--yes` takes the app's own graph
191
+ backup first (`--no-backup` opts out, and then you are on your own).
192
+
193
+ ### The exporter, and why the app's own CLI
194
+
195
+ While Logseq has a graph open its db-worker holds an **exclusive lock** on that
196
+ graph's `db.sqlite`, so an exporter that opens the file directly dies with
197
+ `database is locked` — which is every export while you are actually working.
198
+ The CLI inside the desktop app does not open the file, it asks the running app,
199
+ so it exports mid-edit. That is why the watcher looks for it first: on PATH, at
200
+ `~/.local/bin/logseq`, then the app bundle itself.
201
+
202
+ `--no-app-cli` falls back to the separate [`@logseq/cli`](https://www.npmjs.com/package/@logseq/cli)
203
+ npm package, which opens the graph file directly. It is only useful against a
204
+ graph the app does **not** have open, and on Node 24 it needs a
205
+ `better-sqlite3` override to install at all:
206
+
207
+ ```sh
208
+ mkdir logseq-cli && cd logseq-cli && npm init -y
209
+ npm pkg set overrides.better-sqlite3=12.11.1
210
+ npm i @logseq/cli
211
+ # then: LOGSEQ_CLI_DIR=$PWD logseq-sync --no-app-cli â€Ļ
212
+ ```
213
+
214
+ `--api-server-token` (or `LOGSEQ_API_SERVER_TOKEN`) routes that fallback
215
+ through the app's HTTP API server rather than the file — but `@logseq/cli`
216
+ 0.4.3 hardcodes `http://127.0.0.1:12315` and Logseq 2.0.1 does not listen
217
+ there, so on 2.0.1 this path goes nowhere. Prefer the app CLI.
218
+
219
+ **Settings**: *Vault folder* — where the files are written, and where `restore`
220
+ reads them back from. *Debounce (seconds)* — quiet
221
+ period after the last change before the watcher is signalled (default 5; syncs
222
+ feed git commits, so this is deliberately calmer than UI-style debounce).
223
+
224
+ ### Editing the vault from outside
225
+
226
+ The vault is ordinary text, and that is the point: agents, scripts and plain
227
+ `sed` all work on it, and none of them needs to know Logseq exists. With
228
+ `--two-way` running, an edit imports on the next cycle; without it, run
229
+ `logseq-sync restore` when you are ready.
230
+
231
+ **An agent (Claude, or anything speaking MCP)** gets addressed, validated
232
+ block edits from the [`geml` MCP server](https://github.com/geml-spec/geml):
233
+
234
+ ```sh
235
+ npm i -g @geml/geml
236
+ geml mcp --root <your-vault-dir> --no-history
237
+ ```
238
+
239
+ `--no-history` matters here: git is this vault's history, and without the flag
240
+ every MCP write also saves a `.gemlhistory` sidecar revision beside the file.
241
+ (If you want those too, drop the flag — the sync ignores sidecars either way
242
+ and never commits them.)
243
+
244
+ **A one-liner** reads or edits one block by its address — every block carries
245
+ its uuid:
246
+
247
+ ```sh
248
+ geml find "that phrase" <vault-dir>/.logseq-sync-vault-with-geml # → pages/foo.geml #<uuid>
249
+ geml get <vault-dir>/.logseq-sync-vault-with-geml/pages/foo.geml '#<uuid>'
250
+ printf 'new text' | geml set <vault-dir>/.logseq-sync-vault-with-geml/pages/foo.geml '#<uuid>' --in - -o <same-file>
251
+ ```
252
+
253
+ **Bulk refactoring** is whatever your shell already does — the result is
254
+ re-imported by uuid, so identity survives the edit:
255
+
256
+ ```sh
257
+ grep -rl "old-tag" <vault-dir>/.logseq-sync-vault-with-geml/pages | xargs sed -i 's/old-tag/new-tag/g'
258
+ logseq-sync restore <vault-dir> --yes # or let --two-way pick it up
259
+ ```
260
+
261
+ A check after a bulk edit is cheap insurance — it names a mangled block, and a
262
+ reference that now goes nowhere, before the import carries either into the
263
+ graph:
264
+
265
+ ```sh
266
+ geml check <vault-dir>/.logseq-sync-vault-with-geml/pages/foo.geml --root <vault-dir>/.logseq-sync-vault-with-geml
267
+ ```
268
+
269
+ `--root` is what lets a reference into another page resolve: block refs are
270
+ translated on the way out, so `[[<uuid>]]` in the graph becomes GEML's checked
271
+ `[[#uuid]]` (same page) or `[[../pages/other.geml#uuid]]` (another one), and
272
+ the translation reverses exactly on the way back.
273
+
274
+ ## Honesty corner
275
+
276
+ - The **default** continuous direction is graph → files; going back is a
277
+ deliberate command (`restore`). `--two-way` makes the return trip continuous
278
+ too — every cycle imports what changed in the vault — under three rules that
279
+ say what it does NOT pretend to solve: a file changed on **both** sides
280
+ since the last sync is a conflict, held exactly as you left it (not
281
+ imported, not overwritten, named in the toolbar status until you merge it);
282
+ deletions are **never** imported; and a graph backup is taken before the
283
+ first import and every tenth after. The sync tells its own writes from
284
+ yours by content hash, so nothing echoes.
285
+ - **Files the sync did not write are never touched** — not deleted, and not
286
+ overwritten either. A manifest per tree records what the sync wrote; a file
287
+ on disk that no manifest claims belongs to whoever put it there, so it is
288
+ held and named instead of replaced, and `--mirror` only ever removes files
289
+ from that list. This is what makes it safe to point a vault (or
290
+ `--markdown`) at a graph you already have: your pages survive the first
291
+ sync. A file already byte-identical to what the sync would write is adopted
292
+ rather than held — there is nothing of yours to lose. `--overwrite-unmanaged`
293
+ is how you say you meant it.
294
+ - **The app's lock is the thing to know about.** A running Logseq holds
295
+ `db.sqlite` exclusively, so the `@logseq/cli` export only works with the app
296
+ closed (or on a graph it does not have open). Continuous sync therefore runs
297
+ through the desktop app's own CLI (`--app-cli`), which asks the running app
298
+ instead of touching the file. Verified on 2.0.1: same 9 documents as the
299
+ offline export, byte-identical except three keys of export metadata.
300
+ - **The Markdown tree is an OG graph, and it is a copy.** `--markdown` writes
301
+ Logseq's own file-version dialect — one bullet per block, `id::` for
302
+ identity, `((uuid))` for block refs — so the directory **opens in the file
303
+ version of the app**. It is lossy and one-way: typed properties, tags,
304
+ tables and data blocks have no OG shape and do not survive, the GEML tree
305
+ stays the one that round-trips, and `restore` never reads the Markdown.
306
+ Generic GEML-to-Markdown is `geml <file> --to md`, which belongs to the
307
+ parser; the only reason this integration writes Markdown is Logseq.
308
+ - **Restore merges, it does not replace.** An import lands by uuid over
309
+ whatever the graph currently holds; it will not remove pages the vault no
310
+ longer has. Take the backup.
311
+ - **A graph name you mistype is created, not rejected.** `logseq graph export
312
+ --graph <name>` silently makes a new empty graph rather than failing, and
313
+ syncing that emptiness would wipe the vault's synced files. The watcher
314
+ refuses any graph name it cannot see under `<root>/graphs` first.
315
+ - **A commit that fails is printed, not swallowed.** `git` with no configured
316
+ author (or `user.useConfigOnly`) writes the files and commits nothing;
317
+ `doctor` calls that out up front, and a sync that could not commit says
318
+ `Git: NOT COMMITTED — â€Ļ` rather than just `Synced`.
319
+ - **2.0 renamed the export we read.** `:export-type :graph` now means a datoms
320
+ dump; the `{:pages-and-blocks ...}` shape this converter reads is
321
+ `:graph-human`. The watcher asks for `:graph-human` explicitly.
322
+ - The watcher half is tested end-to-end in CI (a planted fake CLI exports
323
+ fixture EDN, so the signal → re-sync → status round trip runs with no Logseq
324
+ installed). The in-app half is verified against the 2.0.1 runtime — the
325
+ plugin API surface, `hook:db:changed`, the storage-file bridge — and its
326
+ SDK is `@logseq/libs` 0.3.x (the `next` tag). If anything misbehaves in
327
+ your setup, an issue with your Logseq version is gold.
328
+
329
+ ## Proven on a live DB graph, judged by Logseq's own validator
330
+
331
+ `npm test` proves, on fixtures lifted from Logseq's own `deps/db` export tests:
332
+
333
+ 1. **EDN → GEML → EDN is a structural identity** (EDN map/set semantics).
334
+ 2. Every generated document parses as GEML with **zero error diagnostics**.
335
+ 3. A block Logseq considers addressable (exported uuid) is **addressable in
336
+ GEML by the same id**.
337
+ 4. **Editing one block's text changes exactly that block** in the EDN — no
338
+ collateral change anywhere in the graph.
339
+
340
+ And `bin/live-roundtrip.mjs` has confirmed all four against a real DB graph
341
+ (2026-08-20, schema 65.22): export → 6 clean documents → identity; then with
342
+ `--edit`, a `geml set` on one block imported back with `logseq import-edn`,
343
+ **`logseq validate`: Valid!**, and the re-export showed the edit landed **in
344
+ place by uuid, exactly once — whole-graph re-import merges, it does not
345
+ duplicate**.
346
+
347
+ The design and the reasoning live in the
348
+ [GEML monorepo](https://github.com/geml-spec/geml)
349
+ (`docs/design/specs/2026-08-20-logseq-integration-scoping.md`); the community
350
+ threads are
351
+ [logseq/logseq#13086](https://github.com/logseq/logseq/discussions/13086) and
352
+ [the forum post](https://discuss.logseq.com/t/35193).
353
+
354
+ ## Development
355
+
356
+ ```
357
+ core/ converter (mapping.mjs), sync engine, bridge.mjs (the signal/status file contract)
358
+ watcher/ the logseq-sync CLI and its end-to-end tests — published to npm as @geml/logseq-sync
359
+ plugin/ the in-app half (this package.json is the Logseq plugin manifest)
360
+ ```
361
+
362
+ Source of truth is
363
+ [`integrations/logseq/`](https://github.com/geml-spec/geml/tree/main/integrations/logseq)
364
+ in the GEML monorepo; this repository mirrors it for the marketplace and
365
+ carries the releases. Please open issues here, and PRs against the monorepo.
366
+
367
+ The converter is two pure functions in `core/src/mapping.mjs` —
368
+ `ednToGemlFiles(ednText)` and `gemlFilesToEdn(files, lib)` — with the reference
369
+ parser injected. The tests import the parser's build:
370
+
371
+ ```sh
372
+ cd geml-parser && npm install && npm run build && cd ../integrations/logseq
373
+ npm install
374
+ npm test
375
+ ```
376
+
377
+ Live-stage demos (need `@logseq/cli` via `LOGSEQ_CLI_DIR` — see "The exporter" above):
378
+
379
+ ```sh
380
+ node watcher/bin/create-graph.mjs my-graph # create a DB graph WITHOUT the desktop app
381
+ node watcher/bin/live-roundtrip.mjs my-graph # read-only: export → GEML → back → compare
382
+ node watcher/bin/live-roundtrip.mjs my-graph --edit # + geml set → import-edn → logseq validate
383
+ ```
384
+
385
+ Versioning: the MAJOR version tracks the Logseq major it targets — this is
386
+ 2.x because it speaks Logseq 2.x (DB graphs) and nothing older. Minor/patch
387
+ are this package's own.
388
+
389
+ ## Next
390
+
391
+ - Property readability: scalar `:build/properties` as GEML attributes instead
392
+ of the `.block-meta` EDN ride-along (NAME rules permitting).
393
+ - **Write-back**: wiring `syncDiskToEdn` to the CLI so the vault is
394
+ two-way — edit the file, the graph follows.
395
+
396
+ MIT Š GEML contributors