@geml/logseq-sync 2.0.7 â 2.0.8
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 +369 -367
- package/core/src/mapping.mjs +325 -300
- package/core/src/og-markdown.mjs +67 -0
- package/core/src/sync-engine.mjs +460 -449
- package/package.json +2 -2
- package/watcher/bin/logseq-sync.mjs +918 -916
package/README.md
CHANGED
|
@@ -1,367 +1,369 @@
|
|
|
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
|
-

|
|
8
|
-
|
|
9
|
-
Edit a block; seconds later the file on disk has caught up, and the toolbar says
|
|
10
|
-
so:
|
|
11
|
-
|
|
12
|
-

|
|
13
|
-
|
|
14
|
-
Two settings, and only the first one usually needs touching:
|
|
15
|
-
|
|
16
|
-

|
|
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
|
|
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
|
|
157
|
-
| `--interval <seconds>` | heartbeat between signals (default 10) |
|
|
158
|
-
| `--app-cli <path>` | a Logseq CLI the search did not find |
|
|
159
|
-
| `--signal <file>` / `--no-signal` | the plugin bridge, or none |
|
|
160
|
-
|
|
161
|
-
### Going back: `logseq-sync restore`
|
|
162
|
-
|
|
163
|
-
```sh
|
|
164
|
-
logseq-sync restore # rehearse: says what it would import, writes nothing
|
|
165
|
-
logseq-sync restore --yes # take a Logseq backup, then import the vault
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
The vault imports into the graph by block uuid, so an edit lands in place
|
|
169
|
-
rather than duplicating. This is the one direction that writes into your notes,
|
|
170
|
-
so it rehearses unless you pass `--yes`, and `--yes` takes the app's own graph
|
|
171
|
-
backup first (`--no-backup` opts out, and then you are on your own).
|
|
172
|
-
|
|
173
|
-
### The exporter, and why the app's own CLI
|
|
174
|
-
|
|
175
|
-
While Logseq has a graph open its db-worker holds an **exclusive lock** on that
|
|
176
|
-
graph's `db.sqlite`, so an exporter that opens the file directly dies with
|
|
177
|
-
`database is locked` â which is every export while you are actually working.
|
|
178
|
-
The CLI inside the desktop app does not open the file, it asks the running app,
|
|
179
|
-
so it exports mid-edit. That is why the watcher looks for it first: on PATH, at
|
|
180
|
-
`~/.local/bin/logseq`, then the app bundle itself.
|
|
181
|
-
|
|
182
|
-
`--no-app-cli` falls back to the separate [`@logseq/cli`](https://www.npmjs.com/package/@logseq/cli)
|
|
183
|
-
npm package, which opens the graph file directly. It is only useful against a
|
|
184
|
-
graph the app does **not** have open, and on Node 24 it needs a
|
|
185
|
-
`better-sqlite3` override to install at all:
|
|
186
|
-
|
|
187
|
-
```sh
|
|
188
|
-
mkdir logseq-cli && cd logseq-cli && npm init -y
|
|
189
|
-
npm pkg set overrides.better-sqlite3=12.11.1
|
|
190
|
-
npm i @logseq/cli
|
|
191
|
-
# then: LOGSEQ_CLI_DIR=$PWD logseq-sync --no-app-cli âĻ
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
`--api-server-token` (or `LOGSEQ_API_SERVER_TOKEN`) routes that fallback
|
|
195
|
-
through the app's HTTP API server rather than the file â but `@logseq/cli`
|
|
196
|
-
0.4.3 hardcodes `http://127.0.0.1:12315` and Logseq 2.0.1 does not listen
|
|
197
|
-
there, so on 2.0.1 this path goes nowhere. Prefer the app CLI.
|
|
198
|
-
|
|
199
|
-
**Settings**: *Vault folder* â where the files are written, and where `restore`
|
|
200
|
-
reads them back from. *Debounce (seconds)* â quiet
|
|
201
|
-
period after the last change before the watcher is signalled (default 5; syncs
|
|
202
|
-
feed git commits, so this is deliberately calmer than UI-style debounce).
|
|
203
|
-
|
|
204
|
-
### Editing the vault from outside
|
|
205
|
-
|
|
206
|
-
The vault is ordinary text, and that is the point: agents, scripts and plain
|
|
207
|
-
`sed` all work on it, and none of them needs to know Logseq exists. With
|
|
208
|
-
`--two-way` running, an edit imports on the next cycle; without it, run
|
|
209
|
-
`logseq-sync restore` when you are ready.
|
|
210
|
-
|
|
211
|
-
**An agent (Claude, or anything speaking MCP)** gets addressed, validated
|
|
212
|
-
block edits from the [`geml` MCP server](https://github.com/geml-spec/geml):
|
|
213
|
-
|
|
214
|
-
```sh
|
|
215
|
-
npm i -g @geml/geml
|
|
216
|
-
geml mcp --root <your-vault-dir> --no-history
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
`--no-history` matters here: git is this vault's history, and without the flag
|
|
220
|
-
every MCP write also saves a `.gemlhistory` sidecar revision beside the file.
|
|
221
|
-
(If you want those too, drop the flag â the sync ignores sidecars either way
|
|
222
|
-
and never commits them.)
|
|
223
|
-
|
|
224
|
-
**A one-liner** reads or edits one block by its address â every block carries
|
|
225
|
-
its uuid:
|
|
226
|
-
|
|
227
|
-
```sh
|
|
228
|
-
geml find "that phrase" <vault-dir> # â pages/foo.geml #<uuid>
|
|
229
|
-
geml get <vault-dir>/pages/foo.geml '#<uuid>'
|
|
230
|
-
printf 'new text' | geml set <vault-dir>/pages/foo.geml '#<uuid>' --in - -o <same-file>
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
**Bulk refactoring** is whatever your shell already does â the result is
|
|
234
|
-
re-imported by uuid, so identity survives the edit:
|
|
235
|
-
|
|
236
|
-
```sh
|
|
237
|
-
grep -rl "old-tag" <vault-dir>/pages | xargs sed -i 's/old-tag/new-tag/g'
|
|
238
|
-
logseq-sync restore <vault-dir> --yes # or let --two-way pick it up
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
A check after a bulk edit is cheap insurance â it names a mangled block, and a
|
|
242
|
-
reference that now goes nowhere, before the import carries either into the
|
|
243
|
-
graph:
|
|
244
|
-
|
|
245
|
-
```sh
|
|
246
|
-
geml check <vault-dir>/pages/foo.geml --root <vault-dir>
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-
`--root` is what lets a reference into another page resolve: block refs are
|
|
250
|
-
translated on the way out, so `[[<uuid>]]` in the graph becomes GEML's checked
|
|
251
|
-
`[[#uuid]]` (same page) or `[[../pages/other.geml#uuid]]` (another one), and
|
|
252
|
-
the translation reverses exactly on the way back.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
npm
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
node watcher/bin/
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
+

|
|
8
|
+
|
|
9
|
+
Edit a block; seconds later the file on disk has caught up, and the toolbar says
|
|
10
|
+
so:
|
|
11
|
+
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
Two settings, and only the first one usually needs touching:
|
|
15
|
+
|
|
16
|
+

|
|
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
|
+
| `--interval <seconds>` | heartbeat between signals (default 10) |
|
|
158
|
+
| `--app-cli <path>` | a Logseq CLI the search did not find |
|
|
159
|
+
| `--signal <file>` / `--no-signal` | the plugin bridge, or none |
|
|
160
|
+
|
|
161
|
+
### Going back: `logseq-sync restore`
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
logseq-sync restore # rehearse: says what it would import, writes nothing
|
|
165
|
+
logseq-sync restore --yes # take a Logseq backup, then import the vault
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The vault imports into the graph by block uuid, so an edit lands in place
|
|
169
|
+
rather than duplicating. This is the one direction that writes into your notes,
|
|
170
|
+
so it rehearses unless you pass `--yes`, and `--yes` takes the app's own graph
|
|
171
|
+
backup first (`--no-backup` opts out, and then you are on your own).
|
|
172
|
+
|
|
173
|
+
### The exporter, and why the app's own CLI
|
|
174
|
+
|
|
175
|
+
While Logseq has a graph open its db-worker holds an **exclusive lock** on that
|
|
176
|
+
graph's `db.sqlite`, so an exporter that opens the file directly dies with
|
|
177
|
+
`database is locked` â which is every export while you are actually working.
|
|
178
|
+
The CLI inside the desktop app does not open the file, it asks the running app,
|
|
179
|
+
so it exports mid-edit. That is why the watcher looks for it first: on PATH, at
|
|
180
|
+
`~/.local/bin/logseq`, then the app bundle itself.
|
|
181
|
+
|
|
182
|
+
`--no-app-cli` falls back to the separate [`@logseq/cli`](https://www.npmjs.com/package/@logseq/cli)
|
|
183
|
+
npm package, which opens the graph file directly. It is only useful against a
|
|
184
|
+
graph the app does **not** have open, and on Node 24 it needs a
|
|
185
|
+
`better-sqlite3` override to install at all:
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
mkdir logseq-cli && cd logseq-cli && npm init -y
|
|
189
|
+
npm pkg set overrides.better-sqlite3=12.11.1
|
|
190
|
+
npm i @logseq/cli
|
|
191
|
+
# then: LOGSEQ_CLI_DIR=$PWD logseq-sync --no-app-cli âĻ
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`--api-server-token` (or `LOGSEQ_API_SERVER_TOKEN`) routes that fallback
|
|
195
|
+
through the app's HTTP API server rather than the file â but `@logseq/cli`
|
|
196
|
+
0.4.3 hardcodes `http://127.0.0.1:12315` and Logseq 2.0.1 does not listen
|
|
197
|
+
there, so on 2.0.1 this path goes nowhere. Prefer the app CLI.
|
|
198
|
+
|
|
199
|
+
**Settings**: *Vault folder* â where the files are written, and where `restore`
|
|
200
|
+
reads them back from. *Debounce (seconds)* â quiet
|
|
201
|
+
period after the last change before the watcher is signalled (default 5; syncs
|
|
202
|
+
feed git commits, so this is deliberately calmer than UI-style debounce).
|
|
203
|
+
|
|
204
|
+
### Editing the vault from outside
|
|
205
|
+
|
|
206
|
+
The vault is ordinary text, and that is the point: agents, scripts and plain
|
|
207
|
+
`sed` all work on it, and none of them needs to know Logseq exists. With
|
|
208
|
+
`--two-way` running, an edit imports on the next cycle; without it, run
|
|
209
|
+
`logseq-sync restore` when you are ready.
|
|
210
|
+
|
|
211
|
+
**An agent (Claude, or anything speaking MCP)** gets addressed, validated
|
|
212
|
+
block edits from the [`geml` MCP server](https://github.com/geml-spec/geml):
|
|
213
|
+
|
|
214
|
+
```sh
|
|
215
|
+
npm i -g @geml/geml
|
|
216
|
+
geml mcp --root <your-vault-dir> --no-history
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`--no-history` matters here: git is this vault's history, and without the flag
|
|
220
|
+
every MCP write also saves a `.gemlhistory` sidecar revision beside the file.
|
|
221
|
+
(If you want those too, drop the flag â the sync ignores sidecars either way
|
|
222
|
+
and never commits them.)
|
|
223
|
+
|
|
224
|
+
**A one-liner** reads or edits one block by its address â every block carries
|
|
225
|
+
its uuid:
|
|
226
|
+
|
|
227
|
+
```sh
|
|
228
|
+
geml find "that phrase" <vault-dir> # â pages/foo.geml #<uuid>
|
|
229
|
+
geml get <vault-dir>/pages/foo.geml '#<uuid>'
|
|
230
|
+
printf 'new text' | geml set <vault-dir>/pages/foo.geml '#<uuid>' --in - -o <same-file>
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Bulk refactoring** is whatever your shell already does â the result is
|
|
234
|
+
re-imported by uuid, so identity survives the edit:
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
grep -rl "old-tag" <vault-dir>/pages | xargs sed -i 's/old-tag/new-tag/g'
|
|
238
|
+
logseq-sync restore <vault-dir> --yes # or let --two-way pick it up
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
A check after a bulk edit is cheap insurance â it names a mangled block, and a
|
|
242
|
+
reference that now goes nowhere, before the import carries either into the
|
|
243
|
+
graph:
|
|
244
|
+
|
|
245
|
+
```sh
|
|
246
|
+
geml check <vault-dir>/pages/foo.geml --root <vault-dir>
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`--root` is what lets a reference into another page resolve: block refs are
|
|
250
|
+
translated on the way out, so `[[<uuid>]]` in the graph becomes GEML's checked
|
|
251
|
+
`[[#uuid]]` (same page) or `[[../pages/other.geml#uuid]]` (another one), and
|
|
252
|
+
the translation reverses exactly on the way back.
|
|
253
|
+
|
|
254
|
+
## Honesty corner
|
|
255
|
+
|
|
256
|
+
- The **default** continuous direction is graph â files; going back is a
|
|
257
|
+
deliberate command (`restore`). `--two-way` makes the return trip continuous
|
|
258
|
+
too â every cycle imports what changed in the vault â under three rules that
|
|
259
|
+
say what it does NOT pretend to solve: a file changed on **both** sides
|
|
260
|
+
since the last sync is a conflict, held exactly as you left it (not
|
|
261
|
+
imported, not overwritten, named in the toolbar status until you merge it);
|
|
262
|
+
deletions are **never** imported; and a graph backup is taken before the
|
|
263
|
+
first import and every tenth after. The sync tells its own writes from
|
|
264
|
+
yours by content hash, so nothing echoes.
|
|
265
|
+
- Files the sync did not write are never touched: a manifest tracks what it
|
|
266
|
+
owns, and `--mirror` only ever removes files from that list.
|
|
267
|
+
- **The app's lock is the thing to know about.** A running Logseq holds
|
|
268
|
+
`db.sqlite` exclusively, so the `@logseq/cli` export only works with the app
|
|
269
|
+
closed (or on a graph it does not have open). Continuous sync therefore runs
|
|
270
|
+
through the desktop app's own CLI (`--app-cli`), which asks the running app
|
|
271
|
+
instead of touching the file. Verified on 2.0.1: same 9 documents as the
|
|
272
|
+
offline export, byte-identical except three keys of export metadata.
|
|
273
|
+
- **The Markdown tree is an OG graph, and it is a copy.** `--markdown` writes
|
|
274
|
+
Logseq's own file-version dialect â one bullet per block, `id::` for
|
|
275
|
+
identity, `((uuid))` for block refs â so the directory **opens in the file
|
|
276
|
+
version of the app**. It is lossy and one-way: typed properties, tags,
|
|
277
|
+
tables and data blocks have no OG shape and do not survive, the GEML tree
|
|
278
|
+
stays the one that round-trips, and `restore` never reads the Markdown.
|
|
279
|
+
Generic GEML-to-Markdown is `geml <file> --to md`, which belongs to the
|
|
280
|
+
parser; the only reason this integration writes Markdown is Logseq.
|
|
281
|
+
- **Restore merges, it does not replace.** An import lands by uuid over
|
|
282
|
+
whatever the graph currently holds; it will not remove pages the vault no
|
|
283
|
+
longer has. Take the backup.
|
|
284
|
+
- **A graph name you mistype is created, not rejected.** `logseq graph export
|
|
285
|
+
--graph <name>` silently makes a new empty graph rather than failing, and
|
|
286
|
+
syncing that emptiness would wipe the vault's synced files. The watcher
|
|
287
|
+
refuses any graph name it cannot see under `<root>/graphs` first.
|
|
288
|
+
- **A commit that fails is printed, not swallowed.** `git` with no configured
|
|
289
|
+
author (or `user.useConfigOnly`) writes the files and commits nothing;
|
|
290
|
+
`doctor` calls that out up front, and a sync that could not commit says
|
|
291
|
+
`Git: NOT COMMITTED â âĻ` rather than just `Synced`.
|
|
292
|
+
- **2.0 renamed the export we read.** `:export-type :graph` now means a datoms
|
|
293
|
+
dump; the `{:pages-and-blocks ...}` shape this converter reads is
|
|
294
|
+
`:graph-human`. The watcher asks for `:graph-human` explicitly.
|
|
295
|
+
- The watcher half is tested end-to-end in CI (a planted fake CLI exports
|
|
296
|
+
fixture EDN, so the signal â re-sync â status round trip runs with no Logseq
|
|
297
|
+
installed). The in-app half is verified against the 2.0.1 runtime â the
|
|
298
|
+
plugin API surface, `hook:db:changed`, the storage-file bridge â and its
|
|
299
|
+
SDK is `@logseq/libs` 0.3.x (the `next` tag). If anything misbehaves in
|
|
300
|
+
your setup, an issue with your Logseq version is gold.
|
|
301
|
+
|
|
302
|
+
## Proven on a live DB graph, judged by Logseq's own validator
|
|
303
|
+
|
|
304
|
+
`npm test` proves, on fixtures lifted from Logseq's own `deps/db` export tests:
|
|
305
|
+
|
|
306
|
+
1. **EDN â GEML â EDN is a structural identity** (EDN map/set semantics).
|
|
307
|
+
2. Every generated document parses as GEML with **zero error diagnostics**.
|
|
308
|
+
3. A block Logseq considers addressable (exported uuid) is **addressable in
|
|
309
|
+
GEML by the same id**.
|
|
310
|
+
4. **Editing one block's text changes exactly that block** in the EDN â no
|
|
311
|
+
collateral change anywhere in the graph.
|
|
312
|
+
|
|
313
|
+
And `bin/live-roundtrip.mjs` has confirmed all four against a real DB graph
|
|
314
|
+
(2026-08-20, schema 65.22): export â 6 clean documents â identity; then with
|
|
315
|
+
`--edit`, a `geml set` on one block imported back with `logseq import-edn`,
|
|
316
|
+
**`logseq validate`: Valid!**, and the re-export showed the edit landed **in
|
|
317
|
+
place by uuid, exactly once â whole-graph re-import merges, it does not
|
|
318
|
+
duplicate**.
|
|
319
|
+
|
|
320
|
+
The design and the reasoning live in the
|
|
321
|
+
[GEML monorepo](https://github.com/geml-spec/geml)
|
|
322
|
+
(`docs/design/specs/2026-08-20-logseq-integration-scoping.md`); the community
|
|
323
|
+
threads are
|
|
324
|
+
[logseq/logseq#13086](https://github.com/logseq/logseq/discussions/13086) and
|
|
325
|
+
[the forum post](https://discuss.logseq.com/t/35193).
|
|
326
|
+
|
|
327
|
+
## Development
|
|
328
|
+
|
|
329
|
+
```
|
|
330
|
+
core/ converter (mapping.mjs), sync engine, bridge.mjs (the signal/status file contract)
|
|
331
|
+
watcher/ the logseq-sync CLI and its end-to-end tests â published to npm as @geml/logseq-sync
|
|
332
|
+
plugin/ the in-app half (this package.json is the Logseq plugin manifest)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Source of truth is
|
|
336
|
+
[`integrations/logseq/`](https://github.com/geml-spec/geml/tree/main/integrations/logseq)
|
|
337
|
+
in the GEML monorepo; this repository mirrors it for the marketplace and
|
|
338
|
+
carries the releases. Please open issues here, and PRs against the monorepo.
|
|
339
|
+
|
|
340
|
+
The converter is two pure functions in `core/src/mapping.mjs` â
|
|
341
|
+
`ednToGemlFiles(ednText)` and `gemlFilesToEdn(files, lib)` â with the reference
|
|
342
|
+
parser injected. The tests import the parser's build:
|
|
343
|
+
|
|
344
|
+
```sh
|
|
345
|
+
cd geml-parser && npm install && npm run build && cd ../integrations/logseq
|
|
346
|
+
npm install
|
|
347
|
+
npm test
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Live-stage demos (need `@logseq/cli` via `LOGSEQ_CLI_DIR` â see "The exporter" above):
|
|
351
|
+
|
|
352
|
+
```sh
|
|
353
|
+
node watcher/bin/create-graph.mjs my-graph # create a DB graph WITHOUT the desktop app
|
|
354
|
+
node watcher/bin/live-roundtrip.mjs my-graph # read-only: export â GEML â back â compare
|
|
355
|
+
node watcher/bin/live-roundtrip.mjs my-graph --edit # + geml set â import-edn â logseq validate
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Versioning: the MAJOR version tracks the Logseq major it targets â this is
|
|
359
|
+
2.x because it speaks Logseq 2.x (DB graphs) and nothing older. Minor/patch
|
|
360
|
+
are this package's own.
|
|
361
|
+
|
|
362
|
+
## Next
|
|
363
|
+
|
|
364
|
+
- Property readability: scalar `:build/properties` as GEML attributes instead
|
|
365
|
+
of the `.block-meta` EDN ride-along (NAME rules permitting).
|
|
366
|
+
- **Write-back**: wiring `syncDiskToEdn` to the CLI so the vault is
|
|
367
|
+
two-way â edit the file, the graph follows.
|
|
368
|
+
|
|
369
|
+
MIT Š GEML contributors
|