@geml/logseq-sync 2.0.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/LICENSE +28 -0
- package/README.md +212 -0
- package/core/src/bridge.mjs +8 -0
- package/core/src/mapping.mjs +243 -0
- package/core/src/sync-engine.mjs +324 -0
- package/docs/how-it-works.svg +42 -0
- package/package.json +53 -0
- package/watcher/bin/create-graph.mjs +51 -0
- package/watcher/bin/create_graph_headless.cljs +22 -0
- package/watcher/bin/geml-sync.mjs +290 -0
- package/watcher/bin/live-roundtrip.mjs +131 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GEML contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
NOTE: This MIT license covers the *code* in this repository (geml-parser/,
|
|
26
|
+
integrations/geml-viewer/, integrations/geml-check-action/, docs/examples/
|
|
27
|
+
tooling). The *specification* documents are licensed separately under
|
|
28
|
+
CC-BY-4.0 β see LICENSE-spec.md.
|
package/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Sync Vault with GEML
|
|
2
|
+
|
|
3
|
+
Your Logseq DB graph as a **continuously synced, Git-friendly plain-text
|
|
4
|
+
vault** β pages and journals back in readable files and folders, the way OG
|
|
5
|
+
vaults felt, kept in step with the database.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## What you get
|
|
10
|
+
|
|
11
|
+
- πΏ **Real git workflows** β clean commits, readable line-by-line diffs, full
|
|
12
|
+
version history for a DB graph
|
|
13
|
+
- π¦ **A plain-text escape hatch that stays yours** β every page a readable
|
|
14
|
+
file, not a database dump
|
|
15
|
+
- π **Continuous, not one-shot** β edit in Logseq, and seconds later the file
|
|
16
|
+
and its git commit exist
|
|
17
|
+
|
|
18
|
+
Logseq 2.0 ships both ends of a trade-off: `logseq export` gives Markdown
|
|
19
|
+
(readable, lossy) and `logseq export-edn` gives EDN (lossless, not something a
|
|
20
|
+
person edits). The vault's format, [GEML](https://github.com/geml-spec/geml),
|
|
21
|
+
is the point between: **as readable as the Markdown export, as lossless as the
|
|
22
|
+
EDN one** β and addressable, so external tools and agents can edit one block
|
|
23
|
+
of a graph instead of round-tripping all of it.
|
|
24
|
+
|
|
25
|
+
The tree is laid out the way an OG vault is β the thing a file-version user
|
|
26
|
+
recognizes as "my graph, as files again":
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
{:pages-and-blocks [...]} ontology.geml :properties/:classes, verbatim EDN
|
|
30
|
+
β graph.geml page ORDER (an addressable data block,
|
|
31
|
+
so filenames need no numeric prefixes)
|
|
32
|
+
journals/2025_02_20.geml journal pages, OG date names
|
|
33
|
+
pages/<name>.geml one per page:
|
|
34
|
+
block title β `=== text` body
|
|
35
|
+
block uuid β `{#uuid}` β geml get/set address
|
|
36
|
+
outline tree β flat blocks with `level=N`
|
|
37
|
+
everything else rides along in `code {lang=edn}`
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
(`@logseq/cli` 0.4.3's `export-edn` does not include journal pages, so live
|
|
41
|
+
exports show `pages/` only today; the journal mapping is fixture-tested.)
|
|
42
|
+
|
|
43
|
+
## How it works β two halves, one honest boundary
|
|
44
|
+
|
|
45
|
+
A Logseq 2.0 plugin runs in a sandboxed iframe: no arbitrary-path filesystem,
|
|
46
|
+
no git, no shell (verified against the 2.0.1 app bundle). So the in-app plugin
|
|
47
|
+
(`plugin/`) does the only two things only it can do:
|
|
48
|
+
|
|
49
|
+
- **hear** the graph change (`logseq.DB.onChanged`, debounced) and write a
|
|
50
|
+
dirty-marker file through the plugin storage API;
|
|
51
|
+
- **show** the last sync result in the toolbar (`β`) and command palette.
|
|
52
|
+
|
|
53
|
+
Everything with side effects lives in the **watcher** (`watcher/bin/geml-sync.mjs`),
|
|
54
|
+
built on the official `@logseq/cli` export. It reacts to the marker file
|
|
55
|
+
immediately (interval polling stays on as a fallback), writes only the files
|
|
56
|
+
that actually changed β so `git diff` is never noise β commits with a pathspec
|
|
57
|
+
scoped strictly to the vault, and reports back for the toolbar to display.
|
|
58
|
+
The two halves meet in the plugin's own storage directory
|
|
59
|
+
(`<dotdir>/storages/logseq-plugin-sync-vault-with-geml/`), the one disk location both can
|
|
60
|
+
reach. A file as the bridge beats a local HTTP API: no port, no server, no
|
|
61
|
+
CORS.
|
|
62
|
+
|
|
63
|
+
Real output, real DB graph (exported with the official CLI, validated by
|
|
64
|
+
`logseq validate`):
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
$ node watcher/bin/geml-sync.mjs geml-spike ~/vault-demo --git-commit --signal <storage>/geml-sync-dirty.json
|
|
68
|
+
Starting GEML Sync: Graph "geml-spike" β ~/vault-demo
|
|
69
|
+
Git auto-commit: enabled (scoped to target paths)
|
|
70
|
+
[19:29:14] Synced: 8 written, 0 unchanged.
|
|
71
|
+
Git: [master (root-commit) 9cc348c] logseq-geml: sync graph "geml-spike"
|
|
72
|
+
9 files changed, 142 insertions(+)
|
|
73
|
+
create mode 100644 graph.geml
|
|
74
|
+
create mode 100644 pages/contents.geml
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
$ node watcher/bin/geml-sync.mjs geml-spike ~/vault-demo --git-commit --signal ... # run again
|
|
78
|
+
[19:29:55] Graph is up-to-date (0 written, 8 unchanged).
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Setup
|
|
82
|
+
|
|
83
|
+
**1. Install the plugin** from the marketplace β or build and load it
|
|
84
|
+
unpacked (`dist/` is not checked in):
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
cd plugin && npm install && npm run build
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
then Settings β Advanced β Developer mode β "Load unpacked plugin" β `plugin/`.
|
|
91
|
+
|
|
92
|
+
**2. Get the watcher** β one npm install, gives you the `geml-sync` command:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
npm install -g @geml/logseq-sync
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
(or run it ad hoc with `npx @geml/logseq-sync β¦`; the source lives in this
|
|
99
|
+
repository under `watcher/` and `core/`)
|
|
100
|
+
|
|
101
|
+
**3. Install `@logseq/cli`** (one time, anywhere). On Node 24 its
|
|
102
|
+
`better-sqlite3` has no prebuilt binding until 12.11.1, so pin an override
|
|
103
|
+
(without it, install tries to compile and node-gyp does not recognize
|
|
104
|
+
VS 2026 yet):
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
mkdir logseq-cli && cd logseq-cli && npm init -y
|
|
108
|
+
npm pkg set overrides.better-sqlite3=12.11.1
|
|
109
|
+
npm i @logseq/cli
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**4. Run the watcher**, with `LOGSEQ_CLI_DIR` pointing at that directory and
|
|
113
|
+
`--signal` pointing at this plugin's storage directory:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
geml-sync <your-graph> <your-vault-dir> --watch --git-commit \
|
|
117
|
+
--signal <logseq-dotdir>/storages/logseq-plugin-sync-vault-with-geml/geml-sync-dirty.json
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Edit a block in Logseq β the plugin signals β the watcher syncs β the toolbar
|
|
121
|
+
`β` button shows `Sync Vault with GEML: last sync at β¦ β 1 written, 7 unchanged.`
|
|
122
|
+
|
|
123
|
+
**Settings**: *Debounce (seconds)* β quiet period after the last change before
|
|
124
|
+
the watcher is signalled (default 5; syncs feed git commits, so this is
|
|
125
|
+
deliberately calmer than UI-style debounce).
|
|
126
|
+
|
|
127
|
+
## Honesty corner
|
|
128
|
+
|
|
129
|
+
- Sync is **export-direction** today (graph β files, continuously). The
|
|
130
|
+
write-back path (edit a `.geml` file β import back by UUID) is proven in the
|
|
131
|
+
engine (`syncDiskToEdn`) and lands next; deletions are reported, never
|
|
132
|
+
auto-propagated (`--signal` never deletes your hand-written files either β a
|
|
133
|
+
manifest tracks what the sync owns).
|
|
134
|
+
- Journal pages appear as soon as `@logseq/cli` exports them (0.4.3 does not).
|
|
135
|
+
- The watcher half is tested end-to-end in CI (a planted fake CLI exports
|
|
136
|
+
fixture EDN, so the signal β re-sync β status round trip runs with no Logseq
|
|
137
|
+
installed). The in-app half is verified against the 2.0.1 runtime β the
|
|
138
|
+
plugin API surface, `hook:db:changed`, the storage-file bridge β and its
|
|
139
|
+
SDK is `@logseq/libs` 0.3.x (the `next` tag). If anything misbehaves in
|
|
140
|
+
your setup, an issue with your Logseq version is gold.
|
|
141
|
+
|
|
142
|
+
## Proven on a live DB graph, judged by Logseq's own validator
|
|
143
|
+
|
|
144
|
+
`npm test` proves, on fixtures lifted from Logseq's own `deps/db` export tests:
|
|
145
|
+
|
|
146
|
+
1. **EDN β GEML β EDN is a structural identity** (EDN map/set semantics).
|
|
147
|
+
2. Every generated document parses as GEML with **zero error diagnostics**.
|
|
148
|
+
3. A block Logseq considers addressable (exported uuid) is **addressable in
|
|
149
|
+
GEML by the same id**.
|
|
150
|
+
4. **Editing one block's text changes exactly that block** in the EDN β no
|
|
151
|
+
collateral change anywhere in the graph.
|
|
152
|
+
|
|
153
|
+
And `bin/live-roundtrip.mjs` has confirmed all four against a real DB graph
|
|
154
|
+
(2026-08-20, schema 65.22): export β 6 clean documents β identity; then with
|
|
155
|
+
`--edit`, a `geml set` on one block imported back with `logseq import-edn`,
|
|
156
|
+
**`logseq validate`: Valid!**, and the re-export showed the edit landed **in
|
|
157
|
+
place by uuid, exactly once β whole-graph re-import merges, it does not
|
|
158
|
+
duplicate**.
|
|
159
|
+
|
|
160
|
+
The design and the reasoning live in the
|
|
161
|
+
[GEML monorepo](https://github.com/geml-spec/geml)
|
|
162
|
+
(`docs/design/specs/2026-08-20-logseq-integration-scoping.md`); the community
|
|
163
|
+
threads are
|
|
164
|
+
[logseq/logseq#13086](https://github.com/logseq/logseq/discussions/13086) and
|
|
165
|
+
[the forum post](https://discuss.logseq.com/t/35193).
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
core/ converter (mapping.mjs), sync engine, bridge.mjs (the signal/status file contract)
|
|
171
|
+
watcher/ the geml-sync CLI and its end-to-end tests β published to npm as @geml/logseq-sync
|
|
172
|
+
plugin/ the in-app half (this package.json is the Logseq plugin manifest)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Source of truth is
|
|
176
|
+
[`integrations/logseq/`](https://github.com/geml-spec/geml/tree/main/integrations/logseq)
|
|
177
|
+
in the GEML monorepo; this repository mirrors it for the marketplace and
|
|
178
|
+
carries the releases. Please open issues here, and PRs against the monorepo.
|
|
179
|
+
|
|
180
|
+
The converter is two pure functions in `core/src/mapping.mjs` β
|
|
181
|
+
`ednToGemlFiles(ednText)` and `gemlFilesToEdn(files, lib)` β with the reference
|
|
182
|
+
parser injected. The tests import the parser's build:
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
cd geml-parser && npm install && npm run build && cd ../integrations/logseq
|
|
186
|
+
npm install
|
|
187
|
+
npm test
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Live-stage demos (need `@logseq/cli` via `LOGSEQ_CLI_DIR`, see Setup step 3):
|
|
191
|
+
|
|
192
|
+
```sh
|
|
193
|
+
node watcher/bin/create-graph.mjs my-graph # create a DB graph WITHOUT the desktop app
|
|
194
|
+
node watcher/bin/live-roundtrip.mjs my-graph # read-only: export β GEML β back β compare
|
|
195
|
+
node watcher/bin/live-roundtrip.mjs my-graph --edit # + geml set β import-edn β logseq validate
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Versioning: the MAJOR version tracks the Logseq major it targets β this is
|
|
199
|
+
2.x because it speaks Logseq 2.x (DB graphs) and nothing older. Minor/patch
|
|
200
|
+
are this package's own.
|
|
201
|
+
|
|
202
|
+
## Next
|
|
203
|
+
|
|
204
|
+
- **Reference translation**: block refs in titles are literally `[[<uuid>]]`,
|
|
205
|
+
one character away from GEML's checked `[[#uuid]]` β translating them lets
|
|
206
|
+
`geml check` catch broken block refs, the actual headline of the proposal.
|
|
207
|
+
- Property readability: scalar `:build/properties` as GEML attributes instead
|
|
208
|
+
of the `.block-meta` EDN ride-along (NAME rules permitting).
|
|
209
|
+
- **Write-back**: wiring `syncDiskToEdn` to the CLI so the vault is
|
|
210
|
+
two-way β edit the file, the graph follows.
|
|
211
|
+
|
|
212
|
+
MIT Β© GEML contributors
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The contract between the two halves of Sync Vault with GEML. The in-app plugin
|
|
2
|
+
// writes SIGNAL_FILE through logseq.FileStorage; the watcher reacts to it and
|
|
3
|
+
// writes STATUS_FILE back beside it. Both land in the plugin's storage
|
|
4
|
+
// directory (<dotdir>/storages/<plugin-id>/) β the one disk location both
|
|
5
|
+
// sides can reach. These names ARE the protocol: change them only together.
|
|
6
|
+
|
|
7
|
+
export const SIGNAL_FILE = "geml-sync-dirty.json";
|
|
8
|
+
export const STATUS_FILE = "geml-sync-status.json";
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// EDN β GEML for Logseq DB graphs.
|
|
2
|
+
//
|
|
3
|
+
// Input is what `logseq export-edn` (@logseq/cli) produces β sqlite.build EDN:
|
|
4
|
+
//
|
|
5
|
+
// {:pages-and-blocks [{:page {...} :blocks [{:block/title ".." :build/children [..]} ..]} ..]
|
|
6
|
+
// :properties {..} ; ontology: property definitions
|
|
7
|
+
// :classes {..}} ; ontology: class/tag definitions
|
|
8
|
+
//
|
|
9
|
+
// The mapping keeps two promises, in this order:
|
|
10
|
+
//
|
|
11
|
+
// 1. LOSSLESS. The round-trip test is EDN β GEML β EDN structural equality
|
|
12
|
+
// (EDN map/set semantics: entry order does not count). Anything this
|
|
13
|
+
// version does not give a GEML shape of its own rides along VERBATIM as
|
|
14
|
+
// EDN inside `code {lang=edn}` blocks β carried, not dropped.
|
|
15
|
+
// 2. ADDRESSABLE where it pays. A block's title becomes the body of a
|
|
16
|
+
// `=== text` block; a block that has a uuid keeps it as `{#uuid}`, so
|
|
17
|
+
// `geml get/set` address exactly the blocks Logseq itself considers
|
|
18
|
+
// addressable (uuids are only exported for referenced blocks).
|
|
19
|
+
//
|
|
20
|
+
// Structure choice: the outline tree is a FLAT sequence of blocks in
|
|
21
|
+
// depth-first order, each carrying `level=N` β a complete encoding of the tree
|
|
22
|
+
// (it is how outlines print), without nesting GEML fences to the outline's
|
|
23
|
+
// depth.
|
|
24
|
+
//
|
|
25
|
+
// Everything runs on edn-data's TYPED representation (keywords as {key}, sets
|
|
26
|
+
// as {set}, maps as {map: [[k,v]..]}, vectors as arrays), so nothing is coerced
|
|
27
|
+
// through JSON and nothing un-EDN-able is invented.
|
|
28
|
+
|
|
29
|
+
import { parseEDNString, toEDNString } from "edn-data";
|
|
30
|
+
|
|
31
|
+
// --- typed-EDN helpers -------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
const kw = (name) => ({ key: name });
|
|
34
|
+
const isKw = (v, name) => v !== null && typeof v === "object" && v.key === name;
|
|
35
|
+
const mapEntries = (m) => (m !== null && typeof m === "object" && Array.isArray(m.map) ? m.map : []);
|
|
36
|
+
const mapGet = (m, name) => {
|
|
37
|
+
for (const [k, v] of mapEntries(m)) if (isKw(k, name)) return v;
|
|
38
|
+
return undefined;
|
|
39
|
+
};
|
|
40
|
+
const mapWithout = (m, names) => ({ map: mapEntries(m).filter(([k]) => !names.some((n) => isKw(k, n))) });
|
|
41
|
+
const mapSize = (m) => mapEntries(m).length;
|
|
42
|
+
const edn = (v) => toEDNString(v);
|
|
43
|
+
|
|
44
|
+
// edn-data renders `#uuid "..."` as a tagged value; accept both spellings.
|
|
45
|
+
const uuidOf = (v) => {
|
|
46
|
+
if (typeof v === "string") return v;
|
|
47
|
+
if (v && typeof v === "object") {
|
|
48
|
+
if (typeof v.uuid === "string") return v.uuid;
|
|
49
|
+
if (v.tag === "uuid" && typeof v.val === "string") return v.val;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// --- GEML text helpers -------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
// A fence must be longer than any `=` run opening a line of the body (Β§3).
|
|
57
|
+
function fenceFor(body) {
|
|
58
|
+
let longest = 2;
|
|
59
|
+
for (const m of body.matchAll(/^=+/gm)) longest = Math.max(longest, m[0].length + 1);
|
|
60
|
+
return "=".repeat(Math.max(3, longest));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function gemlBlock(type, attrs, body) {
|
|
64
|
+
const f = fenceFor(body);
|
|
65
|
+
const a = attrs ? ` {${attrs}}` : "";
|
|
66
|
+
return `${f} ${type}${a}\n${body}\n${f}\n`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- export: EDN β GEML files ------------------------------------------------
|
|
70
|
+
|
|
71
|
+
// Returns Map<relativePath, gemlText>. Page order is preserved by a numeric
|
|
72
|
+
// filename prefix: :pages-and-blocks is a vector, and order is content.
|
|
73
|
+
export function ednToGemlFiles(ednText) {
|
|
74
|
+
const top = parseEDNString(ednText);
|
|
75
|
+
const files = new Map();
|
|
76
|
+
|
|
77
|
+
const pages = mapGet(top, "pages-and-blocks") ?? [];
|
|
78
|
+
const properties = mapGet(top, "properties");
|
|
79
|
+
const classes = mapGet(top, "classes");
|
|
80
|
+
const rest = mapWithout(top, ["pages-and-blocks", "properties", "classes"]);
|
|
81
|
+
|
|
82
|
+
// Ontology and any top-level keys this version does not model: verbatim.
|
|
83
|
+
let onto = '=== meta\ntitle = "Logseq graph ontology"\n===\n\n';
|
|
84
|
+
if (properties !== undefined) onto += gemlBlock("code", "#properties lang=edn", edn(properties));
|
|
85
|
+
if (classes !== undefined) onto += gemlBlock("code", "#classes lang=edn", edn(classes));
|
|
86
|
+
if (mapSize(rest) > 0) onto += gemlBlock("code", "#graph-extra lang=edn", edn(rest));
|
|
87
|
+
files.set("ontology.geml", onto);
|
|
88
|
+
|
|
89
|
+
const order = [];
|
|
90
|
+
pages.forEach((entry) => {
|
|
91
|
+
const page = mapGet(entry, "page") ?? { map: [] };
|
|
92
|
+
const blocksVal = mapGet(entry, "blocks");
|
|
93
|
+
const blocks = blocksVal ?? [];
|
|
94
|
+
const entryRest = mapWithout(entry, ["page", "blocks"]);
|
|
95
|
+
// A present-but-empty :blocks is not the same EDN as an absent one, and
|
|
96
|
+
// real exports write `:blocks []` on block-less pages. An empty vector has
|
|
97
|
+
// no text blocks to speak for it, so it rides along with the rest.
|
|
98
|
+
if (Array.isArray(blocksVal) && blocksVal.length === 0) entryRest.map.push([kw("blocks"), []]);
|
|
99
|
+
|
|
100
|
+
const title = mapGet(page, "block/title");
|
|
101
|
+
const journal = mapGet(page, "build/journal");
|
|
102
|
+
// The tree is laid out the way an OG vault is: journals under `journals/`
|
|
103
|
+
// with the OG date filename (20250220 β 2025_02_20.geml), everything else
|
|
104
|
+
// under `pages/` named by the page itself. No numeric prefixes β page
|
|
105
|
+
// ORDER is content, but it belongs in the graph.geml index, not in
|
|
106
|
+
// filenames a person has to look at.
|
|
107
|
+
let path;
|
|
108
|
+
if (typeof journal === "number") {
|
|
109
|
+
const j = String(journal);
|
|
110
|
+
path = `journals/${j.slice(0, 4)}_${j.slice(4, 6)}_${j.slice(6, 8)}.geml`;
|
|
111
|
+
} else {
|
|
112
|
+
const nameSeed = typeof title === "string" ? title : "page";
|
|
113
|
+
const slug = nameSeed.toLowerCase().replace(/[^a-z0-9δΈ-ιΏΏ]+/gu, "-").replace(/^-+|-+$/g, "") || "page";
|
|
114
|
+
path = `pages/${slug}.geml`;
|
|
115
|
+
}
|
|
116
|
+
// Two titles may slug identically; the index carries order and identity,
|
|
117
|
+
// so filenames only need to be unique.
|
|
118
|
+
for (let n = 2; files.has(path); n++) path = path.replace(/\.geml$/, "") .replace(/-\d+$/, "") + `-${n}.geml`;
|
|
119
|
+
order.push(path);
|
|
120
|
+
|
|
121
|
+
// The page's identity, verbatim β reconstruction reads THIS; the heading
|
|
122
|
+
// below is presentation, not data.
|
|
123
|
+
let out = gemlBlock("code", ".page-meta lang=edn", edn(page));
|
|
124
|
+
if (mapSize(entryRest) > 0) out += gemlBlock("code", ".page-extra lang=edn", edn(entryRest));
|
|
125
|
+
if (typeof title === "string") out += `\n# ${title}\n\n`;
|
|
126
|
+
|
|
127
|
+
const walk = (bs, level) => {
|
|
128
|
+
for (const b of bs) {
|
|
129
|
+
const btitle = mapGet(b, "block/title");
|
|
130
|
+
const children = mapGet(b, "build/children") ?? [];
|
|
131
|
+
const meta = mapWithout(b, ["block/title", "build/children"]);
|
|
132
|
+
// The uuid stays inside the meta EDN too β losslessness never depends
|
|
133
|
+
// on the id attribute; `{#uuid}` is the ADDRESS.
|
|
134
|
+
const u = uuidOf(mapGet(b, "block/uuid"));
|
|
135
|
+
const id = u ? `#${u} ` : "";
|
|
136
|
+
out += gemlBlock("text", `${id}level=${level}`, typeof btitle === "string" ? btitle : edn(btitle ?? null));
|
|
137
|
+
if (mapSize(meta) > 0) out += gemlBlock("code", ".block-meta lang=edn", edn(meta));
|
|
138
|
+
walk(children, level + 1);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
walk(blocks, 1);
|
|
142
|
+
files.set(path, out);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Page order is content (:pages-and-blocks is a vector), but it lives in the
|
|
146
|
+
// index rather than in filename prefixes: the tree stays human-shaped, and
|
|
147
|
+
// one addressable block carries what the machine needs.
|
|
148
|
+
files.set("graph.geml",
|
|
149
|
+
'=== meta\ntitle = "Logseq graph index"\n===\n\n' +
|
|
150
|
+
gemlBlock("data", "#page-order", JSON.stringify(order, null, 1)));
|
|
151
|
+
|
|
152
|
+
return files;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- import: GEML files β EDN ------------------------------------------------
|
|
156
|
+
|
|
157
|
+
// The parser library is injected ({parse, addressedUnits, sliceUnit} from
|
|
158
|
+
// @geml/geml), so this module stays dependency-light and the caller decides
|
|
159
|
+
// which parser build to trust.
|
|
160
|
+
//
|
|
161
|
+
// Why two reads per document: `parse` gives structure (types, classes, attrs),
|
|
162
|
+
// but a `text` block is FLOW content β its node carries parsed inlines, not
|
|
163
|
+
// raw bytes. The bytes come from `sliceUnit` over the block's span, exactly the
|
|
164
|
+
// route `geml get` takes. Blocks arrive in document order from both, so the
|
|
165
|
+
// two sequences zip.
|
|
166
|
+
export function gemlFilesToEdn(files, lib) {
|
|
167
|
+
const { parse, addressedUnits, sliceUnit } = lib;
|
|
168
|
+
const blocksOf = (text) => {
|
|
169
|
+
const nodes = parse(text).children.filter((c) => c.kind === "block");
|
|
170
|
+
const units = [...addressedUnits(text)].map((a) => a.unit).filter((u) => u.kind === "block");
|
|
171
|
+
return nodes.map((node, i) => ({
|
|
172
|
+
node,
|
|
173
|
+
body: () => {
|
|
174
|
+
const s = sliceUnit(text, units[i].span, "body");
|
|
175
|
+
return s.endsWith("\n") ? s.slice(0, -1) : s;
|
|
176
|
+
},
|
|
177
|
+
}));
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const onto = blocksOf(files.get("ontology.geml") ?? "");
|
|
181
|
+
const grab = (blocks, id) => {
|
|
182
|
+
const b = blocks.find((x) => x.node.id === id);
|
|
183
|
+
return b ? parseEDNString(b.body()) : undefined;
|
|
184
|
+
};
|
|
185
|
+
const properties = grab(onto, "properties");
|
|
186
|
+
const classes = grab(onto, "classes");
|
|
187
|
+
const graphExtra = grab(onto, "graph-extra");
|
|
188
|
+
|
|
189
|
+
// Page order comes from the graph.geml index; a tree without one (hand-built,
|
|
190
|
+
// or index deleted) falls back to path order, which at least is deterministic.
|
|
191
|
+
const indexBlocks = files.has("graph.geml") ? blocksOf(files.get("graph.geml")) : [];
|
|
192
|
+
const orderBlock = indexBlocks.find((b) => b.node.id === "page-order");
|
|
193
|
+
const pagePaths = (orderBlock && Array.isArray(orderBlock.node.value)
|
|
194
|
+
? orderBlock.node.value
|
|
195
|
+
: [...files.keys()].filter((p) => p.startsWith("pages/") || p.startsWith("journals/")).sort()
|
|
196
|
+
).filter((p) => files.has(p));
|
|
197
|
+
const pages = pagePaths.map((p) => {
|
|
198
|
+
let page = { map: [] };
|
|
199
|
+
let entryRest = { map: [] };
|
|
200
|
+
|
|
201
|
+
// Flat level-tagged sequence β tree. Each frame owns the children vector
|
|
202
|
+
// its node's `:build/children` will become; the vector is written into the
|
|
203
|
+
// node only if anything landed in it.
|
|
204
|
+
const roots = [];
|
|
205
|
+
const stack = [{ level: 0, node: null, children: roots }];
|
|
206
|
+
const close = (frame) => {
|
|
207
|
+
if (frame.node && frame.children.length > 0) frame.node.map.push([kw("build/children"), frame.children]);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
let last = null;
|
|
211
|
+
for (const b of blocksOf(files.get(p))) {
|
|
212
|
+
const { type, classes, attrs } = b.node;
|
|
213
|
+
if (type === "code" && classes.includes("page-meta")) { page = parseEDNString(b.body()); continue; }
|
|
214
|
+
if (type === "code" && classes.includes("page-extra")) { entryRest = parseEDNString(b.body()); continue; }
|
|
215
|
+
if (type === "code" && classes.includes("block-meta")) {
|
|
216
|
+
// Meta re-attaches to the block it followed. Splicing the entries into
|
|
217
|
+
// the node keeps one map, as the export wrote it.
|
|
218
|
+
if (last) last.map.push(...mapEntries(parseEDNString(b.body())));
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (type !== "text") continue;
|
|
222
|
+
|
|
223
|
+
const level = typeof attrs["level"] === "number" ? attrs["level"] : 1;
|
|
224
|
+
const node = { map: [[kw("block/title"), b.body()]] };
|
|
225
|
+
while (stack[stack.length - 1].level >= level) close(stack.pop());
|
|
226
|
+
stack[stack.length - 1].children.push(node);
|
|
227
|
+
stack.push({ level, node, children: [] });
|
|
228
|
+
last = node;
|
|
229
|
+
}
|
|
230
|
+
while (stack.length > 1) close(stack.pop());
|
|
231
|
+
|
|
232
|
+
const entry = { map: [[kw("page"), page]] };
|
|
233
|
+
if (roots.length > 0) entry.map.push([kw("blocks"), roots]);
|
|
234
|
+
entry.map.push(...mapEntries(entryRest));
|
|
235
|
+
return entry;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const out = { map: [[kw("pages-and-blocks"), pages]] };
|
|
239
|
+
if (properties !== undefined) out.map.push([kw("properties"), properties]);
|
|
240
|
+
if (classes !== undefined) out.map.push([kw("classes"), classes]);
|
|
241
|
+
if (graphExtra !== undefined) out.map.push(...mapEntries(graphExtra));
|
|
242
|
+
return toEDNString(out);
|
|
243
|
+
}
|