@tensorix/metahub 0.1.7 → 0.1.9
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 +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/cli.js +538 -216
- package/dist/cli.js.map +12 -12
- package/dist/core/db.d.ts +7 -0
- package/dist/core/db.d.ts.map +1 -1
- package/dist/core/documents.d.ts +9 -0
- package/dist/core/documents.d.ts.map +1 -1
- package/dist/core/schema.d.ts +1 -1
- package/dist/core/schema.d.ts.map +1 -1
- package/dist/core/sites.d.ts +21 -0
- package/dist/core/sites.d.ts.map +1 -1
- package/dist/core/sync/sites-routes.d.ts.map +1 -1
- package/dist/core/sync/webui-routes.d.ts.map +1 -1
- package/dist/core/sync/webui.d.ts.map +1 -1
- package/dist/index.js +450 -130
- package/dist/index.js.map +11 -11
- package/dist/webui.js +114 -33
- package/package.json +4 -1
package/dist/cli.js
CHANGED
|
@@ -1116,7 +1116,7 @@ var init_crdt = __esm(() => {
|
|
|
1116
1116
|
},
|
|
1117
1117
|
documents: {
|
|
1118
1118
|
table: "documents",
|
|
1119
|
-
cols: new Set(["title", "body", "database_id", "parent_id", "created_hlc", "__deleted"])
|
|
1119
|
+
cols: new Set(["title", "body", "database_id", "parent_id", "created_hlc", "order_key", "__deleted"])
|
|
1120
1120
|
},
|
|
1121
1121
|
doc_blocks: {
|
|
1122
1122
|
table: "doc_blocks",
|
|
@@ -1172,6 +1172,24 @@ function inferContentType(path) {
|
|
|
1172
1172
|
const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
|
|
1173
1173
|
return MIME[ext] ?? "application/octet-stream";
|
|
1174
1174
|
}
|
|
1175
|
+
function normalizeSiteName(raw) {
|
|
1176
|
+
const slug = raw.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1177
|
+
if (!slug)
|
|
1178
|
+
throw new Error(`invalid site name: ${JSON.stringify(raw)}`);
|
|
1179
|
+
return slug;
|
|
1180
|
+
}
|
|
1181
|
+
function normalizeSitePath(raw) {
|
|
1182
|
+
const segs = [];
|
|
1183
|
+
for (const seg of raw.split("/")) {
|
|
1184
|
+
if (seg === "" || seg === ".")
|
|
1185
|
+
continue;
|
|
1186
|
+
if (seg === "..")
|
|
1187
|
+
segs.pop();
|
|
1188
|
+
else
|
|
1189
|
+
segs.push(seg);
|
|
1190
|
+
}
|
|
1191
|
+
return segs.join("/");
|
|
1192
|
+
}
|
|
1175
1193
|
function isTextType(contentType) {
|
|
1176
1194
|
const ct = contentType.toLowerCase();
|
|
1177
1195
|
return ct.startsWith("text/") || ct.startsWith("application/json") || ct.startsWith("application/manifest+json") || ct.startsWith("application/xml") || ct.startsWith("text/javascript") || ct.startsWith("application/javascript") || ct.startsWith("image/svg+xml");
|
|
@@ -1182,10 +1200,11 @@ function toBytes(data) {
|
|
|
1182
1200
|
return data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
1183
1201
|
}
|
|
1184
1202
|
function createSite(db, opts) {
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
const
|
|
1203
|
+
const name = normalizeSiteName(opts.name);
|
|
1204
|
+
if (getSiteByName(db, name))
|
|
1205
|
+
throw new Error(`site name already exists: ${name}`);
|
|
1206
|
+
const id = newId("site", name);
|
|
1207
|
+
const first = emit(db, "sites", id, "name", name);
|
|
1189
1208
|
emit(db, "sites", id, "created_hlc", first.hlc);
|
|
1190
1209
|
if (opts.title !== undefined)
|
|
1191
1210
|
emit(db, "sites", id, "title", opts.title);
|
|
@@ -1195,7 +1214,13 @@ function getSite(db, id) {
|
|
|
1195
1214
|
return db.query("SELECT id, name, title, created_hlc FROM sites WHERE id = ? AND __deleted = 0").get(id);
|
|
1196
1215
|
}
|
|
1197
1216
|
function getSiteByName(db, name) {
|
|
1198
|
-
|
|
1217
|
+
let slug;
|
|
1218
|
+
try {
|
|
1219
|
+
slug = normalizeSiteName(name);
|
|
1220
|
+
} catch {
|
|
1221
|
+
return null;
|
|
1222
|
+
}
|
|
1223
|
+
return db.query("SELECT id, name, title, created_hlc FROM sites WHERE name = ? AND __deleted = 0 ORDER BY created_hlc DESC LIMIT 1").get(slug);
|
|
1199
1224
|
}
|
|
1200
1225
|
function listSites(db) {
|
|
1201
1226
|
return db.query("SELECT id, name, title, created_hlc FROM sites WHERE __deleted = 0 ORDER BY created_hlc").all();
|
|
@@ -1207,6 +1232,20 @@ function resolveSite(db, ref) {
|
|
|
1207
1232
|
throw new Error(`no such site: ${ref}`);
|
|
1208
1233
|
return site;
|
|
1209
1234
|
}
|
|
1235
|
+
function updateSite(db, id, opts) {
|
|
1236
|
+
if (!getSite(db, id))
|
|
1237
|
+
throw new Error(`no such site: ${id}`);
|
|
1238
|
+
if (opts.name !== undefined) {
|
|
1239
|
+
const name = normalizeSiteName(opts.name);
|
|
1240
|
+
const dup = getSiteByName(db, name);
|
|
1241
|
+
if (dup && dup.id !== id)
|
|
1242
|
+
throw new Error(`site name already exists: ${name}`);
|
|
1243
|
+
emit(db, "sites", id, "name", name);
|
|
1244
|
+
}
|
|
1245
|
+
if (opts.title !== undefined)
|
|
1246
|
+
emit(db, "sites", id, "title", opts.title);
|
|
1247
|
+
return getSite(db, id);
|
|
1248
|
+
}
|
|
1210
1249
|
function deleteSite(db, id) {
|
|
1211
1250
|
if (!getSite(db, id))
|
|
1212
1251
|
return false;
|
|
@@ -1221,7 +1260,10 @@ function fileIdFor(db, siteId, path) {
|
|
|
1221
1260
|
return row?.id ?? null;
|
|
1222
1261
|
}
|
|
1223
1262
|
async function putFile(db, siteId, path, opts) {
|
|
1224
|
-
const
|
|
1263
|
+
const cleanPath = normalizeSitePath(path);
|
|
1264
|
+
if (!cleanPath)
|
|
1265
|
+
throw new Error(`invalid file path: ${JSON.stringify(path)}`);
|
|
1266
|
+
const contentType = opts.contentType ?? inferContentType(cleanPath);
|
|
1225
1267
|
const bytes = toBytes(opts.data);
|
|
1226
1268
|
let encoding;
|
|
1227
1269
|
let content;
|
|
@@ -1235,11 +1277,11 @@ async function putFile(db, siteId, path, opts) {
|
|
|
1235
1277
|
encoding = "blob";
|
|
1236
1278
|
content = (await putBlob(bytes)).hash;
|
|
1237
1279
|
}
|
|
1238
|
-
const existing = fileIdFor(db, siteId,
|
|
1239
|
-
const id = existing ?? newId("sf",
|
|
1280
|
+
const existing = fileIdFor(db, siteId, cleanPath);
|
|
1281
|
+
const id = existing ?? newId("sf", cleanPath);
|
|
1240
1282
|
if (!existing) {
|
|
1241
1283
|
const first = emit(db, "site_files", id, "site_id", siteId);
|
|
1242
|
-
emit(db, "site_files", id, "path",
|
|
1284
|
+
emit(db, "site_files", id, "path", cleanPath);
|
|
1243
1285
|
emit(db, "site_files", id, "created_hlc", first.hlc);
|
|
1244
1286
|
}
|
|
1245
1287
|
emit(db, "site_files", id, "content_type", contentType);
|
|
@@ -1252,7 +1294,7 @@ function listFiles(db, siteId) {
|
|
|
1252
1294
|
return db.query("SELECT id, site_id, path, content_type, encoding FROM site_files WHERE site_id = ? AND __deleted = 0 ORDER BY path").all(siteId);
|
|
1253
1295
|
}
|
|
1254
1296
|
function deleteFile(db, siteId, path) {
|
|
1255
|
-
const id = fileIdFor(db, siteId, path);
|
|
1297
|
+
const id = fileIdFor(db, siteId, normalizeSitePath(path));
|
|
1256
1298
|
if (!id)
|
|
1257
1299
|
return false;
|
|
1258
1300
|
const live = db.query("SELECT __deleted AS d FROM site_files WHERE id = ?").get(id);
|
|
@@ -1262,7 +1304,10 @@ function deleteFile(db, siteId, path) {
|
|
|
1262
1304
|
return true;
|
|
1263
1305
|
}
|
|
1264
1306
|
async function getFileForServe(db, siteId, path) {
|
|
1265
|
-
const
|
|
1307
|
+
const withIndex = path === "" || path.endsWith("/") ? `${path}index.html` : path;
|
|
1308
|
+
const p = normalizeSitePath(withIndex);
|
|
1309
|
+
if (!p)
|
|
1310
|
+
return null;
|
|
1266
1311
|
const row = db.query("SELECT content_type, encoding, content FROM site_files WHERE site_id = ? AND path = ? AND __deleted = 0 ORDER BY created_hlc LIMIT 1").get(siteId, p);
|
|
1267
1312
|
if (!row)
|
|
1268
1313
|
return null;
|
|
@@ -1516,7 +1561,7 @@ var HTML = `<!doctype html>
|
|
|
1516
1561
|
.sb-section-head .add { opacity:0; padding:3px; border-radius:5px; color:var(--muted); transition:opacity .12s; }
|
|
1517
1562
|
.sb-section:hover .add { opacity:1; }
|
|
1518
1563
|
.sb-section-head .add:hover { background:var(--hover-2); color:var(--fg); }
|
|
1519
|
-
.navitem { display:flex; align-items:center; gap:3px; width:100%; text-align:left; padding:5px 6px; border-radius:var(--radius-sm);
|
|
1564
|
+
.navitem { display:flex; align-items:center; gap:3px; width:100%; text-align:left; padding:5px 6px; margin-bottom:2px; border-radius:var(--radius-sm);
|
|
1520
1565
|
color:var(--fg-soft); font-size:13.5px; position:relative; white-space:nowrap; transition:background .1s; }
|
|
1521
1566
|
.navitem:hover { background:var(--hover-2); }
|
|
1522
1567
|
.navitem.active { background:var(--bg); color:var(--fg); font-weight:600; box-shadow:var(--shadow-sm); }
|
|
@@ -1616,6 +1661,21 @@ var HTML = `<!doctype html>
|
|
|
1616
1661
|
.btn-primary:hover { filter:brightness(1.07); }
|
|
1617
1662
|
.btn-danger { background:var(--danger); color:#fff; } .btn-danger:hover { filter:brightness(1.05); }
|
|
1618
1663
|
|
|
1664
|
+
/* desktop app (frameless): drop the sidebar logo; on macOS reserve a draggable
|
|
1665
|
+
strip for the inset traffic lights so they never overlap our controls */
|
|
1666
|
+
body.desktop .brand { display:none; }
|
|
1667
|
+
/* logo gone \u2192 keep the collapse button on the right (where .brand's flex used to push it) */
|
|
1668
|
+
body.desktop .sb-head .iconbtn { margin-left:auto; }
|
|
1669
|
+
body.desktop-mac .sb-head { -webkit-app-region:drag; }
|
|
1670
|
+
body.desktop-mac .sb-head .iconbtn { -webkit-app-region:no-drag; }
|
|
1671
|
+
body.desktop-mac .topbar { -webkit-app-region:drag; }
|
|
1672
|
+
body.desktop-mac .topbar .iconbtn, body.desktop-mac .topbar .btn { -webkit-app-region:no-drag; }
|
|
1673
|
+
/* peek drawer header overlaps the topbar drag region \u2192 clear its buttons so they stay clickable */
|
|
1674
|
+
body.desktop-mac .peek-head { -webkit-app-region:drag; }
|
|
1675
|
+
body.desktop-mac .peek-head .iconbtn, body.desktop-mac .peek-head .btn { -webkit-app-region:no-drag; }
|
|
1676
|
+
/* sidebar collapsed \u2192 traffic lights sit over the main header; clear the hamburger */
|
|
1677
|
+
body.desktop-mac .sidebar.collapsed ~ .main .topbar { padding-left:80px; }
|
|
1678
|
+
|
|
1619
1679
|
/* document editor */
|
|
1620
1680
|
.doc { max-width:740px; margin:0 auto; padding:60px 60px 36vh; }
|
|
1621
1681
|
.doc-title { font-size:38px; font-weight:700; letter-spacing:-.02em; outline:none; line-height:1.15; margin-bottom:4px; }
|
|
@@ -1847,6 +1907,83 @@ var HTML = `<!doctype html>
|
|
|
1847
1907
|
.gridfoot { display:flex; gap:18px; color:var(--muted); font-size:12px; padding:8px 2px; }
|
|
1848
1908
|
input.inlineedit { width:100%; border:0; outline:2px solid var(--accent); border-radius:4px; padding:7px 9px; background:var(--bg); color:var(--fg); font:inherit; }
|
|
1849
1909
|
|
|
1910
|
+
/* ---- sites (\u7AD9\u70B9\u7BA1\u7406) ---- */
|
|
1911
|
+
.sites-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(264px, 1fr)); gap:14px; margin-top:6px; }
|
|
1912
|
+
.site-card { display:flex; flex-direction:column; gap:13px; padding:16px 16px 14px; background:var(--bg); border:1px solid var(--line);
|
|
1913
|
+
border-radius:var(--radius); box-shadow:var(--shadow-sm); cursor:pointer; transition:box-shadow .16s, border-color .16s, transform .16s; }
|
|
1914
|
+
.site-card:hover { box-shadow:var(--shadow-md); border-color:var(--line-strong); transform:translateY(-1px); }
|
|
1915
|
+
.site-card-head { display:flex; align-items:center; gap:11px; min-width:0; }
|
|
1916
|
+
.site-card .si { width:40px; height:40px; border-radius:11px; background:var(--accent-soft); color:var(--accent); display:grid; place-items:center; flex:none; }
|
|
1917
|
+
.site-card .si svg { width:20px; height:20px; }
|
|
1918
|
+
.site-card-id { min-width:0; }
|
|
1919
|
+
.site-card .slug { font-family:var(--mono); font-weight:600; font-size:14.5px; color:var(--fg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1920
|
+
.site-card .ttl { font-size:12px; color:var(--fg-soft); margin-top:1px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1921
|
+
.site-card .ttl.muted { color:var(--muted); font-style:italic; }
|
|
1922
|
+
.site-addr { display:flex; align-items:center; gap:6px; width:100%; text-align:left; background:var(--surface); border:1px solid var(--line);
|
|
1923
|
+
border-radius:var(--radius-sm); padding:6px 9px; color:var(--muted); font-family:var(--mono); font-size:11.5px; transition:border-color .12s, color .12s; }
|
|
1924
|
+
.site-addr svg { flex:none; }
|
|
1925
|
+
.site-addr span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1926
|
+
.site-addr:hover { border-color:var(--accent); color:var(--accent); }
|
|
1927
|
+
.site-card-meta { display:flex; align-items:center; gap:7px; font-size:12px; color:var(--muted); }
|
|
1928
|
+
.site-card-meta b { color:var(--fg-soft); font-weight:600; font-variant-numeric:tabular-nums; }
|
|
1929
|
+
.site-card-meta .dot { color:var(--line-strong); }
|
|
1930
|
+
.site-card-foot { display:flex; align-items:center; gap:6px; padding-top:12px; margin-top:1px; border-top:1px solid var(--line); }
|
|
1931
|
+
.site-card-foot .btn-secondary { padding:6px 12px; justify-content:center; }
|
|
1932
|
+
|
|
1933
|
+
.site-empty { text-align:center; color:var(--muted); border:1px dashed var(--line-strong); border-radius:var(--radius); padding:48px 24px; margin-top:8px; }
|
|
1934
|
+
.site-empty .ei { font-size:30px; margin-bottom:10px; }
|
|
1935
|
+
.site-empty .et { font-size:14px; color:var(--fg-soft); margin-bottom:4px; font-weight:600; }
|
|
1936
|
+
.site-empty .ed { font-size:12.5px; margin-bottom:16px; }
|
|
1937
|
+
|
|
1938
|
+
/* site peek: access bar + file manager */
|
|
1939
|
+
.acc-link { display:flex; align-items:center; gap:6px; background:var(--surface); border:1px solid var(--line); border-radius:var(--radius-sm); padding:7px 8px 7px 11px; margin-bottom:16px; }
|
|
1940
|
+
.acc-link .url { flex:1; font-family:var(--mono); font-size:12.5px; color:var(--fg-soft); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1941
|
+
.acc-link button { width:28px; height:28px; display:grid; place-items:center; border-radius:6px; color:var(--muted); flex:none; }
|
|
1942
|
+
.acc-link button:hover { background:var(--hover-2); color:var(--fg); }
|
|
1943
|
+
.acc-link button.accent { color:var(--accent); }
|
|
1944
|
+
.site-meta { display:flex; gap:18px; color:var(--muted); font-size:12px; margin-bottom:20px; }
|
|
1945
|
+
.site-meta b { color:var(--fg); font-weight:600; }
|
|
1946
|
+
.files-head { display:flex; align-items:center; justify-content:space-between; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); margin:0 0 4px; }
|
|
1947
|
+
.filerow { display:flex; align-items:center; gap:10px; padding:9px 8px; border-radius:var(--radius-sm); cursor:pointer; border-bottom:1px solid var(--line); }
|
|
1948
|
+
.filerow:last-child { border-bottom:0; }
|
|
1949
|
+
.filerow:hover { background:var(--hover); }
|
|
1950
|
+
.filerow .fi { color:var(--muted); display:grid; place-items:center; flex:none; }
|
|
1951
|
+
.filerow .fpath { flex:1; font-family:var(--mono); font-size:12.5px; color:var(--fg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1952
|
+
.filerow .facts { display:flex; gap:1px; opacity:0; flex:none; }
|
|
1953
|
+
.filerow:hover .facts { opacity:1; }
|
|
1954
|
+
.filerow .facts button { width:27px; height:27px; display:grid; place-items:center; border-radius:6px; color:var(--muted); }
|
|
1955
|
+
.filerow .facts button:hover { background:var(--hover-2); color:var(--fg); }
|
|
1956
|
+
.filerow .facts button.del:hover { color:var(--danger); }
|
|
1957
|
+
.enc-badge { display:inline-flex; align-items:center; font-family:var(--mono); font-size:10.5px; font-weight:500; letter-spacing:.02em;
|
|
1958
|
+
padding:2px 6px; border-radius:5px; background:var(--surface-2); color:var(--fg-soft); border:1px solid var(--line); }
|
|
1959
|
+
.enc-badge.blob { color:var(--accent); background:var(--accent-soft); border-color:transparent; }
|
|
1960
|
+
|
|
1961
|
+
/* file preview (inside modal) */
|
|
1962
|
+
.preview-box { font-family:var(--mono); font-size:12.5px; background:var(--surface-2); border:1px solid var(--line); border-radius:var(--radius);
|
|
1963
|
+
padding:14px 16px; max-height:52vh; overflow:auto; white-space:pre-wrap; line-height:1.6; color:var(--fg); }
|
|
1964
|
+
.preview-bin { text-align:center; color:var(--muted); border:1px dashed var(--line-strong); border-radius:var(--radius); padding:40px 20px; }
|
|
1965
|
+
.preview-bin .pi { font-size:28px; margin-bottom:8px; }
|
|
1966
|
+
.preview-bin .pt { color:var(--fg-soft); font-size:13px; }
|
|
1967
|
+
|
|
1968
|
+
/* in-app site preview (browser-framed iframe) */
|
|
1969
|
+
.spv-scrim { position:fixed; inset:0; background:rgba(15,15,25,.5); z-index:85; display:grid; place-items:center; padding:28px;
|
|
1970
|
+
opacity:0; pointer-events:none; transition:opacity .16s; animation:spv-in .16s forwards; }
|
|
1971
|
+
@keyframes spv-in { to { opacity:1; pointer-events:auto; } }
|
|
1972
|
+
.spv-scrim.open { opacity:1; pointer-events:auto; }
|
|
1973
|
+
.spv { width:min(1100px, 92vw); height:88vh; background:var(--bg); border-radius:14px; box-shadow:var(--shadow-lg);
|
|
1974
|
+
display:flex; flex-direction:column; overflow:hidden; }
|
|
1975
|
+
.spv-chrome { display:flex; align-items:center; gap:10px; padding:9px 12px; border-bottom:1px solid var(--line); background:var(--surface); }
|
|
1976
|
+
.spv-dots { display:flex; gap:6px; flex:none; }
|
|
1977
|
+
.spv-dots i { width:11px; height:11px; border-radius:50%; background:var(--line-strong); }
|
|
1978
|
+
.spv-url { flex:1; display:flex; align-items:center; gap:6px; min-width:0; background:var(--bg); border:1px solid var(--line);
|
|
1979
|
+
border-radius:7px; padding:5px 10px; color:var(--fg-soft); font-family:var(--mono); font-size:12px; }
|
|
1980
|
+
.spv-url svg { color:var(--muted); flex:none; }
|
|
1981
|
+
.spv-url span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1982
|
+
.spv-btn { display:inline-flex; align-items:center; gap:5px; flex:none; padding:6px 10px; border-radius:7px; color:var(--fg-soft); font-size:12.5px; font-weight:500; }
|
|
1983
|
+
.spv-btn:hover { background:var(--hover-2); color:var(--fg); }
|
|
1984
|
+
.spv-btn.icon { padding:6px; }
|
|
1985
|
+
.spv-frame { flex:1; width:100%; border:0; background:#fff; }
|
|
1986
|
+
|
|
1850
1987
|
.selbar { position:fixed; bottom:24px; left:50%; transform:translateX(-50%); z-index:65; display:flex; align-items:center; gap:4px;
|
|
1851
1988
|
background:var(--fg); color:var(--bg); padding:7px 8px 7px 14px; border-radius:11px; box-shadow:var(--shadow-lg); }
|
|
1852
1989
|
.selbar .cnt { font-size:13px; font-weight:600; margin-right:6px; }
|
|
@@ -3593,7 +3730,7 @@ async function runMain(cmd, opts = {}) {
|
|
|
3593
3730
|
// package.json
|
|
3594
3731
|
var package_default = {
|
|
3595
3732
|
name: "@tensorix/metahub",
|
|
3596
|
-
version: "0.1.
|
|
3733
|
+
version: "0.1.9",
|
|
3597
3734
|
description: "Metahub core library and CLI",
|
|
3598
3735
|
license: "AGPL-3.0-only",
|
|
3599
3736
|
type: "module",
|
|
@@ -3630,10 +3767,13 @@ var package_default = {
|
|
|
3630
3767
|
citty: "^0.1.6",
|
|
3631
3768
|
"highlight.js": "^11.11.1",
|
|
3632
3769
|
preact: "^10.29.2",
|
|
3770
|
+
turndown: "^7.2.4",
|
|
3771
|
+
"turndown-plugin-gfm": "^1.0.2",
|
|
3633
3772
|
zod: "^4.4.3"
|
|
3634
3773
|
},
|
|
3635
3774
|
devDependencies: {
|
|
3636
3775
|
"@types/bun": "latest",
|
|
3776
|
+
"@types/turndown": "^5.0.6",
|
|
3637
3777
|
typescript: "^5"
|
|
3638
3778
|
}
|
|
3639
3779
|
};
|
|
@@ -3741,6 +3881,7 @@ CREATE TABLE IF NOT EXISTS documents (
|
|
|
3741
3881
|
database_id TEXT,
|
|
3742
3882
|
parent_id TEXT,
|
|
3743
3883
|
created_hlc TEXT,
|
|
3884
|
+
order_key TEXT, -- fractional index among siblings (same parent_id); ties broken by created_hlc,id
|
|
3744
3885
|
__deleted INTEGER NOT NULL DEFAULT 0
|
|
3745
3886
|
);
|
|
3746
3887
|
|
|
@@ -4383,6 +4524,291 @@ function deleteRecord(db, id) {
|
|
|
4383
4524
|
return true;
|
|
4384
4525
|
}
|
|
4385
4526
|
|
|
4527
|
+
// src/core/documents.ts
|
|
4528
|
+
init_ids();
|
|
4529
|
+
init_crdt();
|
|
4530
|
+
function liveBlocks(db, docId) {
|
|
4531
|
+
return db.query("SELECT id, text, order_key, blank_after FROM doc_blocks WHERE doc_id = ? AND __deleted = 0 ORDER BY order_key, id").all(docId);
|
|
4532
|
+
}
|
|
4533
|
+
function makeBlockId(text) {
|
|
4534
|
+
return newId("blk", text.split(`
|
|
4535
|
+
`, 1)[0] ?? "");
|
|
4536
|
+
}
|
|
4537
|
+
function emitBlock(db, docId, fields) {
|
|
4538
|
+
const id = makeBlockId(fields.text);
|
|
4539
|
+
emit(db, "doc_blocks", id, "doc_id", docId);
|
|
4540
|
+
emit(db, "doc_blocks", id, "order_key", fields.order_key);
|
|
4541
|
+
if (fields.blankAfter)
|
|
4542
|
+
emit(db, "doc_blocks", id, "blank_after", fields.blankAfter);
|
|
4543
|
+
emit(db, "doc_blocks", id, "text", fields.text);
|
|
4544
|
+
}
|
|
4545
|
+
function insertBlocks(db, docId, blocks, after, before) {
|
|
4546
|
+
const keys = keysBetween(after, before, blocks.length);
|
|
4547
|
+
blocks.forEach((b2, i2) => emitBlock(db, docId, { text: b2.text, order_key: keys[i2], blankAfter: b2.blankAfter }));
|
|
4548
|
+
}
|
|
4549
|
+
function ensureBlocks(db, docId) {
|
|
4550
|
+
if (db.query("SELECT 1 AS x FROM doc_blocks WHERE doc_id = ? LIMIT 1").get(docId))
|
|
4551
|
+
return;
|
|
4552
|
+
const row = db.query("SELECT body FROM documents WHERE id = ?").get(docId);
|
|
4553
|
+
const blocks = parseDocBlocks(row?.body ?? "");
|
|
4554
|
+
if (blocks.length)
|
|
4555
|
+
insertBlocks(db, docId, blocks, null, null);
|
|
4556
|
+
}
|
|
4557
|
+
function reconcileBody(db, docId, body) {
|
|
4558
|
+
const old = liveBlocks(db, docId);
|
|
4559
|
+
const next = parseDocBlocks(body);
|
|
4560
|
+
const plan = reconcile(old.map((b2) => b2.text), next.map((b2) => b2.text));
|
|
4561
|
+
for (const oi of plan.deleted)
|
|
4562
|
+
emit(db, "doc_blocks", old[oi].id, "__deleted", 1);
|
|
4563
|
+
const { items } = plan;
|
|
4564
|
+
let i2 = 0;
|
|
4565
|
+
while (i2 < items.length) {
|
|
4566
|
+
const it = items[i2];
|
|
4567
|
+
if ("keep" in it) {
|
|
4568
|
+
const oldB = old[it.keep];
|
|
4569
|
+
const want = next[i2].blankAfter;
|
|
4570
|
+
if ((oldB.blank_after ?? 0) !== want)
|
|
4571
|
+
emit(db, "doc_blocks", oldB.id, "blank_after", want);
|
|
4572
|
+
i2++;
|
|
4573
|
+
continue;
|
|
4574
|
+
}
|
|
4575
|
+
let j = i2;
|
|
4576
|
+
while (j < items.length && "insert" in items[j])
|
|
4577
|
+
j++;
|
|
4578
|
+
const left = i2 > 0 ? old[items[i2 - 1].keep].order_key : null;
|
|
4579
|
+
const right = j < items.length ? old[items[j].keep].order_key : null;
|
|
4580
|
+
insertBlocks(db, docId, next.slice(i2, j), left, right);
|
|
4581
|
+
i2 = j;
|
|
4582
|
+
}
|
|
4583
|
+
}
|
|
4584
|
+
function documentVersion(db, id) {
|
|
4585
|
+
const row = db.query(`SELECT MAX(hlc) AS h FROM crdt_changes
|
|
4586
|
+
WHERE (dataset = 'documents' AND row_id = ?)
|
|
4587
|
+
OR (dataset = 'doc_blocks' AND row_id IN (SELECT id FROM doc_blocks WHERE doc_id = ?))`).get(id, id);
|
|
4588
|
+
return row.h ?? "";
|
|
4589
|
+
}
|
|
4590
|
+
function createDocument(db, opts) {
|
|
4591
|
+
const id = newId("doc", opts.title);
|
|
4592
|
+
const first = emit(db, "documents", id, "title", opts.title);
|
|
4593
|
+
emit(db, "documents", id, "created_hlc", first.hlc);
|
|
4594
|
+
if (opts.database_id !== undefined)
|
|
4595
|
+
emit(db, "documents", id, "database_id", opts.database_id);
|
|
4596
|
+
if (opts.parent_id !== undefined)
|
|
4597
|
+
emit(db, "documents", id, "parent_id", opts.parent_id);
|
|
4598
|
+
placeInSiblings(db, id, opts.parent_id ?? null);
|
|
4599
|
+
if (opts.body !== undefined) {
|
|
4600
|
+
const blocks = parseDocBlocks(opts.body);
|
|
4601
|
+
if (blocks.length)
|
|
4602
|
+
insertBlocks(db, id, blocks, null, null);
|
|
4603
|
+
}
|
|
4604
|
+
return getDocument(db, id);
|
|
4605
|
+
}
|
|
4606
|
+
function getDocument(db, id) {
|
|
4607
|
+
return db.query("SELECT id, title, body, database_id, parent_id, created_hlc, order_key FROM documents WHERE id = ? AND __deleted = 0").get(id);
|
|
4608
|
+
}
|
|
4609
|
+
var ORDER_BY = "ORDER BY order_key IS NULL, order_key, created_hlc, id";
|
|
4610
|
+
function listDocuments(db, opts = {}) {
|
|
4611
|
+
const cols = "SELECT id, title, database_id, parent_id, created_hlc, order_key FROM documents WHERE __deleted = 0";
|
|
4612
|
+
if (opts.parent_id !== undefined)
|
|
4613
|
+
return db.query(`${cols} AND parent_id = ? ${ORDER_BY}`).all(opts.parent_id);
|
|
4614
|
+
if (opts.database_id)
|
|
4615
|
+
return db.query(`${cols} AND database_id = ? ${ORDER_BY}`).all(opts.database_id);
|
|
4616
|
+
return db.query(`${cols} ${ORDER_BY}`).all();
|
|
4617
|
+
}
|
|
4618
|
+
function siblingWhere(parentId) {
|
|
4619
|
+
return parentId === null ? { clause: "parent_id IS NULL", args: [] } : { clause: "parent_id = ?", args: [parentId] };
|
|
4620
|
+
}
|
|
4621
|
+
function canEmitOrderKeys2(db) {
|
|
4622
|
+
return tableExists2(db, "meta") && tableExists2(db, "crdt_changes");
|
|
4623
|
+
}
|
|
4624
|
+
function tableExists2(db, table) {
|
|
4625
|
+
return db.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) != null;
|
|
4626
|
+
}
|
|
4627
|
+
function lastSiblingOrderKey(db, parentId, excludeId) {
|
|
4628
|
+
const w2 = siblingWhere(parentId);
|
|
4629
|
+
const exclude = excludeId ? " AND id <> ?" : "";
|
|
4630
|
+
const row = db.query(`SELECT order_key FROM documents WHERE ${w2.clause}${exclude}
|
|
4631
|
+
AND __deleted = 0 AND order_key IS NOT NULL
|
|
4632
|
+
ORDER BY order_key DESC, id DESC LIMIT 1`).get(...w2.args, ...excludeId ? [excludeId] : []);
|
|
4633
|
+
return row?.order_key ?? null;
|
|
4634
|
+
}
|
|
4635
|
+
function orderedSiblings(db, parentId) {
|
|
4636
|
+
const w2 = siblingWhere(parentId);
|
|
4637
|
+
return db.query(`SELECT id, order_key FROM documents WHERE ${w2.clause} AND __deleted = 0 ${ORDER_BY}`).all(...w2.args);
|
|
4638
|
+
}
|
|
4639
|
+
function rebalanceSiblings(db, parentId) {
|
|
4640
|
+
const rows = orderedSiblings(db, parentId);
|
|
4641
|
+
const keys = keysBetween(null, null, rows.length);
|
|
4642
|
+
rows.forEach((row, i2) => {
|
|
4643
|
+
if (row.order_key !== keys[i2])
|
|
4644
|
+
emit(db, "documents", row.id, "order_key", keys[i2]);
|
|
4645
|
+
});
|
|
4646
|
+
}
|
|
4647
|
+
function backfillSiblings(db, parentId) {
|
|
4648
|
+
const emitChange = canEmitOrderKeys2(db);
|
|
4649
|
+
const w2 = siblingWhere(parentId);
|
|
4650
|
+
const missing = db.query(`SELECT id FROM documents WHERE ${w2.clause} AND __deleted = 0 AND order_key IS NULL
|
|
4651
|
+
ORDER BY created_hlc, id`).all(...w2.args);
|
|
4652
|
+
if (!missing.length)
|
|
4653
|
+
return;
|
|
4654
|
+
const keys = keysBetween(lastSiblingOrderKey(db, parentId), null, missing.length);
|
|
4655
|
+
missing.forEach((row, i2) => {
|
|
4656
|
+
if (emitChange)
|
|
4657
|
+
emit(db, "documents", row.id, "order_key", keys[i2]);
|
|
4658
|
+
else
|
|
4659
|
+
db.query("UPDATE documents SET order_key = ? WHERE id = ?").run(keys[i2], row.id);
|
|
4660
|
+
});
|
|
4661
|
+
}
|
|
4662
|
+
function backfillDocumentOrderKeys(db) {
|
|
4663
|
+
const parents = db.query("SELECT DISTINCT parent_id FROM documents WHERE __deleted = 0 AND order_key IS NULL").all();
|
|
4664
|
+
for (const p of parents)
|
|
4665
|
+
backfillSiblings(db, p.parent_id);
|
|
4666
|
+
}
|
|
4667
|
+
function placeInSiblings(db, id, parentId, anchor) {
|
|
4668
|
+
let cur = parentId;
|
|
4669
|
+
while (cur) {
|
|
4670
|
+
if (cur === id)
|
|
4671
|
+
throw new Error(`cannot set parent_id: would create a cycle (${id})`);
|
|
4672
|
+
cur = getDocument(db, cur)?.parent_id ?? null;
|
|
4673
|
+
}
|
|
4674
|
+
const current = getDocument(db, id);
|
|
4675
|
+
if (current && (current.parent_id ?? null) !== parentId)
|
|
4676
|
+
emit(db, "documents", id, "parent_id", parentId);
|
|
4677
|
+
let key;
|
|
4678
|
+
if (!anchor) {
|
|
4679
|
+
key = keyBetween(lastSiblingOrderKey(db, parentId, id), null);
|
|
4680
|
+
} else {
|
|
4681
|
+
backfillSiblings(db, parentId);
|
|
4682
|
+
const neighbors = () => {
|
|
4683
|
+
const rows = orderedSiblings(db, parentId).filter((r3) => r3.id !== id);
|
|
4684
|
+
const to = rows.findIndex((r3) => r3.id === anchor.targetId);
|
|
4685
|
+
if (to < 0)
|
|
4686
|
+
throw new Error(`no such target document: ${anchor.targetId}`);
|
|
4687
|
+
return anchor.where === "before" ? [rows[to - 1]?.order_key ?? null, rows[to].order_key] : [rows[to].order_key, rows[to + 1]?.order_key ?? null];
|
|
4688
|
+
};
|
|
4689
|
+
let [left, right] = neighbors();
|
|
4690
|
+
if (left !== null && right !== null && left >= right) {
|
|
4691
|
+
rebalanceSiblings(db, parentId);
|
|
4692
|
+
[left, right] = neighbors();
|
|
4693
|
+
}
|
|
4694
|
+
key = keyBetween(left, right);
|
|
4695
|
+
}
|
|
4696
|
+
emit(db, "documents", id, "order_key", key);
|
|
4697
|
+
}
|
|
4698
|
+
function moveDocument(db, id, targetId, where) {
|
|
4699
|
+
const src2 = getDocument(db, id);
|
|
4700
|
+
if (!src2)
|
|
4701
|
+
throw new Error(`no such document: ${id}`);
|
|
4702
|
+
if (id === targetId)
|
|
4703
|
+
return src2;
|
|
4704
|
+
const target = getDocument(db, targetId);
|
|
4705
|
+
if (!target)
|
|
4706
|
+
throw new Error(`no such target document: ${targetId}`);
|
|
4707
|
+
if (where === "into")
|
|
4708
|
+
placeInSiblings(db, id, targetId);
|
|
4709
|
+
else
|
|
4710
|
+
placeInSiblings(db, id, target.parent_id, { targetId, where });
|
|
4711
|
+
return getDocument(db, id);
|
|
4712
|
+
}
|
|
4713
|
+
function updateDocument(db, id, fields) {
|
|
4714
|
+
if (!getDocument(db, id))
|
|
4715
|
+
throw new Error(`no such document: ${id}`);
|
|
4716
|
+
if (fields.title !== undefined)
|
|
4717
|
+
emit(db, "documents", id, "title", fields.title);
|
|
4718
|
+
if (fields.database_id !== undefined)
|
|
4719
|
+
emit(db, "documents", id, "database_id", fields.database_id);
|
|
4720
|
+
if (fields.parent_id !== undefined) {
|
|
4721
|
+
const next = fields.parent_id ?? null;
|
|
4722
|
+
if ((getDocument(db, id).parent_id ?? null) !== next)
|
|
4723
|
+
placeInSiblings(db, id, next);
|
|
4724
|
+
}
|
|
4725
|
+
if (fields.body !== undefined) {
|
|
4726
|
+
ensureBlocks(db, id);
|
|
4727
|
+
reconcileBody(db, id, fields.body);
|
|
4728
|
+
}
|
|
4729
|
+
return getDocument(db, id);
|
|
4730
|
+
}
|
|
4731
|
+
function editDocument(db, id, opts) {
|
|
4732
|
+
if (!getDocument(db, id))
|
|
4733
|
+
throw new Error(`no such document: ${id}`);
|
|
4734
|
+
if (opts.ifMatch !== undefined && documentVersion(db, id) !== opts.ifMatch)
|
|
4735
|
+
throw new Error(`stale: document changed since ${opts.ifMatch}; re-read first`);
|
|
4736
|
+
if (opts.old === "")
|
|
4737
|
+
throw new Error("--old must not be empty");
|
|
4738
|
+
ensureBlocks(db, id);
|
|
4739
|
+
const blocks = liveBlocks(db, id);
|
|
4740
|
+
const body = serializeDocBlocks(blocks.map((b2) => ({ text: b2.text, blankAfter: b2.blank_after })));
|
|
4741
|
+
const count = countOccurrences(body, opts.old);
|
|
4742
|
+
if (count === 0)
|
|
4743
|
+
throw new Error(`anchor not found: no match for --old`);
|
|
4744
|
+
if (count > 1 && !opts.replaceAll)
|
|
4745
|
+
throw new Error(`${count} matches for --old; add surrounding context or use --replace-all`);
|
|
4746
|
+
if (!opts.old.includes(`
|
|
4747
|
+
|
|
4748
|
+
`)) {
|
|
4749
|
+
const affected = blocks.filter((b2) => b2.text.includes(opts.old));
|
|
4750
|
+
const next = affected.map((b2) => opts.replaceAll ? b2.text.replaceAll(opts.old, opts.new) : b2.text.replace(opts.old, opts.new));
|
|
4751
|
+
if (next.every((t2) => t2.length > 0 && !t2.includes(`
|
|
4752
|
+
|
|
4753
|
+
`))) {
|
|
4754
|
+
const targets = opts.replaceAll ? affected : affected.slice(0, 1);
|
|
4755
|
+
targets.forEach((b2, i2) => emit(db, "doc_blocks", b2.id, "text", next[i2]));
|
|
4756
|
+
return { id, changed: true, replaced: opts.replaceAll ? count : 1, version: documentVersion(db, id) };
|
|
4757
|
+
}
|
|
4758
|
+
}
|
|
4759
|
+
const newBody = opts.replaceAll ? body.replaceAll(opts.old, opts.new) : body.replace(opts.old, opts.new);
|
|
4760
|
+
reconcileBody(db, id, newBody);
|
|
4761
|
+
return { id, changed: newBody !== body, replaced: opts.replaceAll ? count : 1, version: documentVersion(db, id) };
|
|
4762
|
+
}
|
|
4763
|
+
function appendDocument(db, id, body) {
|
|
4764
|
+
if (!getDocument(db, id))
|
|
4765
|
+
throw new Error(`no such document: ${id}`);
|
|
4766
|
+
ensureBlocks(db, id);
|
|
4767
|
+
const next = parseDocBlocks(body);
|
|
4768
|
+
if (next.length) {
|
|
4769
|
+
const blocks = liveBlocks(db, id);
|
|
4770
|
+
const last = blocks.length ? blocks[blocks.length - 1].order_key : null;
|
|
4771
|
+
insertBlocks(db, id, next, last, null);
|
|
4772
|
+
}
|
|
4773
|
+
return { id, changed: next.length > 0, replaced: next.length, version: documentVersion(db, id) };
|
|
4774
|
+
}
|
|
4775
|
+
function prependDocument(db, id, body) {
|
|
4776
|
+
if (!getDocument(db, id))
|
|
4777
|
+
throw new Error(`no such document: ${id}`);
|
|
4778
|
+
ensureBlocks(db, id);
|
|
4779
|
+
const next = parseDocBlocks(body);
|
|
4780
|
+
if (next.length) {
|
|
4781
|
+
const blocks = liveBlocks(db, id);
|
|
4782
|
+
const first = blocks.length ? blocks[0].order_key : null;
|
|
4783
|
+
insertBlocks(db, id, next, null, first);
|
|
4784
|
+
}
|
|
4785
|
+
return { id, changed: next.length > 0, replaced: next.length, version: documentVersion(db, id) };
|
|
4786
|
+
}
|
|
4787
|
+
function deleteDocument(db, id) {
|
|
4788
|
+
if (!getDocument(db, id))
|
|
4789
|
+
return false;
|
|
4790
|
+
emit(db, "documents", id, "__deleted", 1);
|
|
4791
|
+
for (const b2 of liveChildIds(db, "doc_blocks", "doc_id", id))
|
|
4792
|
+
emit(db, "doc_blocks", b2, "__deleted", 1);
|
|
4793
|
+
for (const c3 of liveChildIds(db, "documents", "parent_id", id))
|
|
4794
|
+
emit(db, "documents", c3, "parent_id", null);
|
|
4795
|
+
return true;
|
|
4796
|
+
}
|
|
4797
|
+
function liveChildIds(db, table, col, parentId) {
|
|
4798
|
+
return db.query(`SELECT id FROM ${table} WHERE ${col} = ? AND __deleted = 0`).all(parentId).map((r3) => r3.id);
|
|
4799
|
+
}
|
|
4800
|
+
function countOccurrences(haystack, needle) {
|
|
4801
|
+
if (needle === "")
|
|
4802
|
+
return 0;
|
|
4803
|
+
let n2 = 0;
|
|
4804
|
+
let i2 = haystack.indexOf(needle);
|
|
4805
|
+
while (i2 !== -1) {
|
|
4806
|
+
n2++;
|
|
4807
|
+
i2 = haystack.indexOf(needle, i2 + needle.length);
|
|
4808
|
+
}
|
|
4809
|
+
return n2;
|
|
4810
|
+
}
|
|
4811
|
+
|
|
4386
4812
|
// src/core/db.ts
|
|
4387
4813
|
function ensureDirs() {
|
|
4388
4814
|
mkdirSync(metahubHome(), { recursive: true });
|
|
@@ -4402,7 +4828,7 @@ function hasColumn(db, table, column) {
|
|
|
4402
4828
|
const cols = db.query(`PRAGMA table_info(${table})`).all();
|
|
4403
4829
|
return cols.some((c3) => c3.name === column);
|
|
4404
4830
|
}
|
|
4405
|
-
function
|
|
4831
|
+
function tableExists3(db, table) {
|
|
4406
4832
|
return db.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) != null;
|
|
4407
4833
|
}
|
|
4408
4834
|
function migrateRecords(db) {
|
|
@@ -4410,7 +4836,7 @@ function migrateRecords(db) {
|
|
|
4410
4836
|
db.exec("ALTER TABLE records ADD COLUMN data TEXT NOT NULL DEFAULT '{}'");
|
|
4411
4837
|
if (!hasColumn(db, "records", "order_key"))
|
|
4412
4838
|
db.exec("ALTER TABLE records ADD COLUMN order_key TEXT");
|
|
4413
|
-
if (!
|
|
4839
|
+
if (!tableExists3(db, "record_values"))
|
|
4414
4840
|
return;
|
|
4415
4841
|
const tx = db.transaction(() => {
|
|
4416
4842
|
db.exec(`
|
|
@@ -4445,6 +4871,11 @@ function migrateDocBlocks(db) {
|
|
|
4445
4871
|
if (!hasColumn(db, "doc_blocks", "blank_after"))
|
|
4446
4872
|
db.exec("ALTER TABLE doc_blocks ADD COLUMN blank_after INTEGER NOT NULL DEFAULT 0");
|
|
4447
4873
|
}
|
|
4874
|
+
function migrateDocuments(db) {
|
|
4875
|
+
if (!hasColumn(db, "documents", "order_key"))
|
|
4876
|
+
db.exec("ALTER TABLE documents ADD COLUMN order_key TEXT");
|
|
4877
|
+
backfillDocumentOrderKeys(db);
|
|
4878
|
+
}
|
|
4448
4879
|
function openMetahub() {
|
|
4449
4880
|
ensureDirs();
|
|
4450
4881
|
const db = new Database(dbPath(), { create: true });
|
|
@@ -4453,6 +4884,7 @@ function openMetahub() {
|
|
|
4453
4884
|
migrateRecords(db);
|
|
4454
4885
|
migratePeers(db);
|
|
4455
4886
|
migrateDocBlocks(db);
|
|
4887
|
+
migrateDocuments(db);
|
|
4456
4888
|
return db;
|
|
4457
4889
|
}
|
|
4458
4890
|
|
|
@@ -4620,198 +5052,6 @@ var use_default = defineCommand({
|
|
|
4620
5052
|
})
|
|
4621
5053
|
});
|
|
4622
5054
|
|
|
4623
|
-
// src/core/documents.ts
|
|
4624
|
-
init_ids();
|
|
4625
|
-
init_crdt();
|
|
4626
|
-
function liveBlocks(db, docId) {
|
|
4627
|
-
return db.query("SELECT id, text, order_key, blank_after FROM doc_blocks WHERE doc_id = ? AND __deleted = 0 ORDER BY order_key, id").all(docId);
|
|
4628
|
-
}
|
|
4629
|
-
function makeBlockId(text) {
|
|
4630
|
-
return newId("blk", text.split(`
|
|
4631
|
-
`, 1)[0] ?? "");
|
|
4632
|
-
}
|
|
4633
|
-
function emitBlock(db, docId, fields) {
|
|
4634
|
-
const id = makeBlockId(fields.text);
|
|
4635
|
-
emit(db, "doc_blocks", id, "doc_id", docId);
|
|
4636
|
-
emit(db, "doc_blocks", id, "order_key", fields.order_key);
|
|
4637
|
-
if (fields.blankAfter)
|
|
4638
|
-
emit(db, "doc_blocks", id, "blank_after", fields.blankAfter);
|
|
4639
|
-
emit(db, "doc_blocks", id, "text", fields.text);
|
|
4640
|
-
}
|
|
4641
|
-
function insertBlocks(db, docId, blocks, after, before) {
|
|
4642
|
-
const keys = keysBetween(after, before, blocks.length);
|
|
4643
|
-
blocks.forEach((b2, i2) => emitBlock(db, docId, { text: b2.text, order_key: keys[i2], blankAfter: b2.blankAfter }));
|
|
4644
|
-
}
|
|
4645
|
-
function ensureBlocks(db, docId) {
|
|
4646
|
-
if (db.query("SELECT 1 AS x FROM doc_blocks WHERE doc_id = ? LIMIT 1").get(docId))
|
|
4647
|
-
return;
|
|
4648
|
-
const row = db.query("SELECT body FROM documents WHERE id = ?").get(docId);
|
|
4649
|
-
const blocks = parseDocBlocks(row?.body ?? "");
|
|
4650
|
-
if (blocks.length)
|
|
4651
|
-
insertBlocks(db, docId, blocks, null, null);
|
|
4652
|
-
}
|
|
4653
|
-
function reconcileBody(db, docId, body) {
|
|
4654
|
-
const old = liveBlocks(db, docId);
|
|
4655
|
-
const next = parseDocBlocks(body);
|
|
4656
|
-
const plan = reconcile(old.map((b2) => b2.text), next.map((b2) => b2.text));
|
|
4657
|
-
for (const oi of plan.deleted)
|
|
4658
|
-
emit(db, "doc_blocks", old[oi].id, "__deleted", 1);
|
|
4659
|
-
const { items } = plan;
|
|
4660
|
-
let i2 = 0;
|
|
4661
|
-
while (i2 < items.length) {
|
|
4662
|
-
const it = items[i2];
|
|
4663
|
-
if ("keep" in it) {
|
|
4664
|
-
const oldB = old[it.keep];
|
|
4665
|
-
const want = next[i2].blankAfter;
|
|
4666
|
-
if ((oldB.blank_after ?? 0) !== want)
|
|
4667
|
-
emit(db, "doc_blocks", oldB.id, "blank_after", want);
|
|
4668
|
-
i2++;
|
|
4669
|
-
continue;
|
|
4670
|
-
}
|
|
4671
|
-
let j = i2;
|
|
4672
|
-
while (j < items.length && "insert" in items[j])
|
|
4673
|
-
j++;
|
|
4674
|
-
const left = i2 > 0 ? old[items[i2 - 1].keep].order_key : null;
|
|
4675
|
-
const right = j < items.length ? old[items[j].keep].order_key : null;
|
|
4676
|
-
insertBlocks(db, docId, next.slice(i2, j), left, right);
|
|
4677
|
-
i2 = j;
|
|
4678
|
-
}
|
|
4679
|
-
}
|
|
4680
|
-
function documentVersion(db, id) {
|
|
4681
|
-
const row = db.query(`SELECT MAX(hlc) AS h FROM crdt_changes
|
|
4682
|
-
WHERE (dataset = 'documents' AND row_id = ?)
|
|
4683
|
-
OR (dataset = 'doc_blocks' AND row_id IN (SELECT id FROM doc_blocks WHERE doc_id = ?))`).get(id, id);
|
|
4684
|
-
return row.h ?? "";
|
|
4685
|
-
}
|
|
4686
|
-
function createDocument(db, opts) {
|
|
4687
|
-
const id = newId("doc", opts.title);
|
|
4688
|
-
const first = emit(db, "documents", id, "title", opts.title);
|
|
4689
|
-
emit(db, "documents", id, "created_hlc", first.hlc);
|
|
4690
|
-
if (opts.database_id !== undefined)
|
|
4691
|
-
emit(db, "documents", id, "database_id", opts.database_id);
|
|
4692
|
-
if (opts.parent_id !== undefined)
|
|
4693
|
-
emit(db, "documents", id, "parent_id", opts.parent_id);
|
|
4694
|
-
if (opts.body !== undefined) {
|
|
4695
|
-
const blocks = parseDocBlocks(opts.body);
|
|
4696
|
-
if (blocks.length)
|
|
4697
|
-
insertBlocks(db, id, blocks, null, null);
|
|
4698
|
-
}
|
|
4699
|
-
return getDocument(db, id);
|
|
4700
|
-
}
|
|
4701
|
-
function getDocument(db, id) {
|
|
4702
|
-
return db.query("SELECT id, title, body, database_id, parent_id, created_hlc FROM documents WHERE id = ? AND __deleted = 0").get(id);
|
|
4703
|
-
}
|
|
4704
|
-
function listDocuments(db, opts = {}) {
|
|
4705
|
-
const cols = "SELECT id, title, database_id, parent_id, created_hlc FROM documents WHERE __deleted = 0";
|
|
4706
|
-
if (opts.parent_id !== undefined)
|
|
4707
|
-
return db.query(`${cols} AND parent_id = ? ORDER BY created_hlc`).all(opts.parent_id);
|
|
4708
|
-
if (opts.database_id)
|
|
4709
|
-
return db.query(`${cols} AND database_id = ? ORDER BY created_hlc`).all(opts.database_id);
|
|
4710
|
-
return db.query(`${cols} ORDER BY created_hlc`).all();
|
|
4711
|
-
}
|
|
4712
|
-
function updateDocument(db, id, fields) {
|
|
4713
|
-
if (!getDocument(db, id))
|
|
4714
|
-
throw new Error(`no such document: ${id}`);
|
|
4715
|
-
if (fields.title !== undefined)
|
|
4716
|
-
emit(db, "documents", id, "title", fields.title);
|
|
4717
|
-
if (fields.database_id !== undefined)
|
|
4718
|
-
emit(db, "documents", id, "database_id", fields.database_id);
|
|
4719
|
-
if (fields.parent_id !== undefined) {
|
|
4720
|
-
let cur = fields.parent_id;
|
|
4721
|
-
while (cur) {
|
|
4722
|
-
if (cur === id)
|
|
4723
|
-
throw new Error(`cannot set parent_id: would create a cycle (${id})`);
|
|
4724
|
-
cur = getDocument(db, cur)?.parent_id ?? null;
|
|
4725
|
-
}
|
|
4726
|
-
emit(db, "documents", id, "parent_id", fields.parent_id);
|
|
4727
|
-
}
|
|
4728
|
-
if (fields.body !== undefined) {
|
|
4729
|
-
ensureBlocks(db, id);
|
|
4730
|
-
reconcileBody(db, id, fields.body);
|
|
4731
|
-
}
|
|
4732
|
-
return getDocument(db, id);
|
|
4733
|
-
}
|
|
4734
|
-
function editDocument(db, id, opts) {
|
|
4735
|
-
if (!getDocument(db, id))
|
|
4736
|
-
throw new Error(`no such document: ${id}`);
|
|
4737
|
-
if (opts.ifMatch !== undefined && documentVersion(db, id) !== opts.ifMatch)
|
|
4738
|
-
throw new Error(`stale: document changed since ${opts.ifMatch}; re-read first`);
|
|
4739
|
-
if (opts.old === "")
|
|
4740
|
-
throw new Error("--old must not be empty");
|
|
4741
|
-
ensureBlocks(db, id);
|
|
4742
|
-
const blocks = liveBlocks(db, id);
|
|
4743
|
-
const body = serializeDocBlocks(blocks.map((b2) => ({ text: b2.text, blankAfter: b2.blank_after })));
|
|
4744
|
-
const count = countOccurrences(body, opts.old);
|
|
4745
|
-
if (count === 0)
|
|
4746
|
-
throw new Error(`anchor not found: no match for --old`);
|
|
4747
|
-
if (count > 1 && !opts.replaceAll)
|
|
4748
|
-
throw new Error(`${count} matches for --old; add surrounding context or use --replace-all`);
|
|
4749
|
-
if (!opts.old.includes(`
|
|
4750
|
-
|
|
4751
|
-
`)) {
|
|
4752
|
-
const affected = blocks.filter((b2) => b2.text.includes(opts.old));
|
|
4753
|
-
const next = affected.map((b2) => opts.replaceAll ? b2.text.replaceAll(opts.old, opts.new) : b2.text.replace(opts.old, opts.new));
|
|
4754
|
-
if (next.every((t2) => t2.length > 0 && !t2.includes(`
|
|
4755
|
-
|
|
4756
|
-
`))) {
|
|
4757
|
-
const targets = opts.replaceAll ? affected : affected.slice(0, 1);
|
|
4758
|
-
targets.forEach((b2, i2) => emit(db, "doc_blocks", b2.id, "text", next[i2]));
|
|
4759
|
-
return { id, changed: true, replaced: opts.replaceAll ? count : 1, version: documentVersion(db, id) };
|
|
4760
|
-
}
|
|
4761
|
-
}
|
|
4762
|
-
const newBody = opts.replaceAll ? body.replaceAll(opts.old, opts.new) : body.replace(opts.old, opts.new);
|
|
4763
|
-
reconcileBody(db, id, newBody);
|
|
4764
|
-
return { id, changed: newBody !== body, replaced: opts.replaceAll ? count : 1, version: documentVersion(db, id) };
|
|
4765
|
-
}
|
|
4766
|
-
function appendDocument(db, id, body) {
|
|
4767
|
-
if (!getDocument(db, id))
|
|
4768
|
-
throw new Error(`no such document: ${id}`);
|
|
4769
|
-
ensureBlocks(db, id);
|
|
4770
|
-
const next = parseDocBlocks(body);
|
|
4771
|
-
if (next.length) {
|
|
4772
|
-
const blocks = liveBlocks(db, id);
|
|
4773
|
-
const last = blocks.length ? blocks[blocks.length - 1].order_key : null;
|
|
4774
|
-
insertBlocks(db, id, next, last, null);
|
|
4775
|
-
}
|
|
4776
|
-
return { id, changed: next.length > 0, replaced: next.length, version: documentVersion(db, id) };
|
|
4777
|
-
}
|
|
4778
|
-
function prependDocument(db, id, body) {
|
|
4779
|
-
if (!getDocument(db, id))
|
|
4780
|
-
throw new Error(`no such document: ${id}`);
|
|
4781
|
-
ensureBlocks(db, id);
|
|
4782
|
-
const next = parseDocBlocks(body);
|
|
4783
|
-
if (next.length) {
|
|
4784
|
-
const blocks = liveBlocks(db, id);
|
|
4785
|
-
const first = blocks.length ? blocks[0].order_key : null;
|
|
4786
|
-
insertBlocks(db, id, next, null, first);
|
|
4787
|
-
}
|
|
4788
|
-
return { id, changed: next.length > 0, replaced: next.length, version: documentVersion(db, id) };
|
|
4789
|
-
}
|
|
4790
|
-
function deleteDocument(db, id) {
|
|
4791
|
-
if (!getDocument(db, id))
|
|
4792
|
-
return false;
|
|
4793
|
-
emit(db, "documents", id, "__deleted", 1);
|
|
4794
|
-
for (const b2 of liveChildIds(db, "doc_blocks", "doc_id", id))
|
|
4795
|
-
emit(db, "doc_blocks", b2, "__deleted", 1);
|
|
4796
|
-
for (const c3 of liveChildIds(db, "documents", "parent_id", id))
|
|
4797
|
-
emit(db, "documents", c3, "parent_id", null);
|
|
4798
|
-
return true;
|
|
4799
|
-
}
|
|
4800
|
-
function liveChildIds(db, table2, col, parentId) {
|
|
4801
|
-
return db.query(`SELECT id FROM ${table2} WHERE ${col} = ? AND __deleted = 0`).all(parentId).map((r3) => r3.id);
|
|
4802
|
-
}
|
|
4803
|
-
function countOccurrences(haystack, needle) {
|
|
4804
|
-
if (needle === "")
|
|
4805
|
-
return 0;
|
|
4806
|
-
let n2 = 0;
|
|
4807
|
-
let i2 = haystack.indexOf(needle);
|
|
4808
|
-
while (i2 !== -1) {
|
|
4809
|
-
n2++;
|
|
4810
|
-
i2 = haystack.indexOf(needle, i2 + needle.length);
|
|
4811
|
-
}
|
|
4812
|
-
return n2;
|
|
4813
|
-
}
|
|
4814
|
-
|
|
4815
5055
|
// src/cli/commands/get.ts
|
|
4816
5056
|
var get_default = defineCommand({
|
|
4817
5057
|
meta: { name: "get", description: "Look up any entity by ref (id/prefix/name), type auto-detected" },
|
|
@@ -22755,7 +22995,7 @@ var PropertySchema = exports_external.object({
|
|
|
22755
22995
|
var RecordSchema = exports_external.object({
|
|
22756
22996
|
id: exports_external.string(),
|
|
22757
22997
|
database_id: exports_external.string(),
|
|
22758
|
-
values: exports_external.record(exports_external.string(), exports_external.any())
|
|
22998
|
+
values: exports_external.record(exports_external.string(), exports_external.any()).describe('Field values keyed by property name (e.g. {"Amount":35}). Reads nest cells here; writes take a flat {column: value} map.')
|
|
22759
22999
|
});
|
|
22760
23000
|
var MoveRecordReq = exports_external.object({
|
|
22761
23001
|
target: exports_external.string(),
|
|
@@ -22766,7 +23006,12 @@ var DocumentSummarySchema = exports_external.object({
|
|
|
22766
23006
|
title: exports_external.string(),
|
|
22767
23007
|
database_id: exports_external.string().nullable(),
|
|
22768
23008
|
parent_id: exports_external.string().nullable(),
|
|
22769
|
-
created_hlc: exports_external.string()
|
|
23009
|
+
created_hlc: exports_external.string(),
|
|
23010
|
+
order_key: exports_external.string().nullable()
|
|
23011
|
+
});
|
|
23012
|
+
var MoveDocumentReq = exports_external.object({
|
|
23013
|
+
target: exports_external.string(),
|
|
23014
|
+
where: exports_external.enum(["before", "after", "into"])
|
|
22770
23015
|
});
|
|
22771
23016
|
var DocumentSchema = DocumentSummarySchema.extend({ body: exports_external.string().nullable() });
|
|
22772
23017
|
var SearchHitSchema = exports_external.object({
|
|
@@ -22795,7 +23040,7 @@ var UpdatePropertyReq = exports_external.object({
|
|
|
22795
23040
|
position: exports_external.number().optional()
|
|
22796
23041
|
});
|
|
22797
23042
|
var SetWidthReq = exports_external.object({ width: exports_external.number() });
|
|
22798
|
-
var RecordValuesReq = exports_external.record(exports_external.string(), exports_external.any());
|
|
23043
|
+
var RecordValuesReq = exports_external.record(exports_external.string(), exports_external.any()).describe("Flat { column: value } cell patch (keyed by property name).");
|
|
22799
23044
|
var CreateDocumentReq = exports_external.object({
|
|
22800
23045
|
title: exports_external.string(),
|
|
22801
23046
|
body: exports_external.string().optional(),
|
|
@@ -23022,6 +23267,17 @@ var webuiRoutes = [
|
|
|
23022
23267
|
return updateDocument(db, need(req, "id"), body);
|
|
23023
23268
|
})
|
|
23024
23269
|
},
|
|
23270
|
+
{
|
|
23271
|
+
method: "PATCH",
|
|
23272
|
+
path: "/api/document/move",
|
|
23273
|
+
summary: "Move a document before/after another, or into it as a child. Query: ?id=<id>",
|
|
23274
|
+
request: MoveDocumentReq,
|
|
23275
|
+
response: DocumentSchema,
|
|
23276
|
+
handler: handle(async (req, { db }) => {
|
|
23277
|
+
const body = await req.json();
|
|
23278
|
+
return moveDocument(db, need(req, "id"), body.target, body.where);
|
|
23279
|
+
})
|
|
23280
|
+
},
|
|
23025
23281
|
{
|
|
23026
23282
|
method: "DELETE",
|
|
23027
23283
|
path: "/api/document",
|
|
@@ -23049,6 +23305,7 @@ var SiteSchema = exports_external.object({
|
|
|
23049
23305
|
title: exports_external.string().nullable(),
|
|
23050
23306
|
created_hlc: exports_external.string()
|
|
23051
23307
|
});
|
|
23308
|
+
var SiteWithCountSchema = SiteSchema.extend({ file_count: exports_external.number() });
|
|
23052
23309
|
var SiteFileSchema = exports_external.object({
|
|
23053
23310
|
id: exports_external.string(),
|
|
23054
23311
|
site_id: exports_external.string(),
|
|
@@ -23056,6 +23313,8 @@ var SiteFileSchema = exports_external.object({
|
|
|
23056
23313
|
content_type: exports_external.string(),
|
|
23057
23314
|
encoding: exports_external.string()
|
|
23058
23315
|
});
|
|
23316
|
+
var CreateSiteBody = exports_external.object({ name: exports_external.string(), title: exports_external.string().optional() });
|
|
23317
|
+
var UpdateSiteBody = exports_external.object({ name: exports_external.string().optional(), title: exports_external.string().optional() });
|
|
23059
23318
|
function need2(req, key) {
|
|
23060
23319
|
const v3 = new URL(req.url).searchParams.get(key);
|
|
23061
23320
|
if (!v3)
|
|
@@ -23075,9 +23334,9 @@ var sitesRoutes = [
|
|
|
23075
23334
|
{
|
|
23076
23335
|
method: "GET",
|
|
23077
23336
|
path: "/api/sites",
|
|
23078
|
-
summary: "List published sites",
|
|
23079
|
-
response: exports_external.array(
|
|
23080
|
-
handler: handle2((_req, { db }) => listSites(db))
|
|
23337
|
+
summary: "List published sites (with file counts)",
|
|
23338
|
+
response: exports_external.array(SiteWithCountSchema),
|
|
23339
|
+
handler: handle2((_req, { db }) => listSites(db).map((s2) => ({ ...s2, file_count: listFiles(db, s2.id).length })))
|
|
23081
23340
|
},
|
|
23082
23341
|
{
|
|
23083
23342
|
method: "GET",
|
|
@@ -23085,6 +23344,60 @@ var sitesRoutes = [
|
|
|
23085
23344
|
summary: "List a site's files (manifest). Query: ?site=<id|name>",
|
|
23086
23345
|
response: exports_external.array(SiteFileSchema),
|
|
23087
23346
|
handler: handle2((req, { db }) => listFiles(db, resolveSite(db, need2(req, "site")).id))
|
|
23347
|
+
},
|
|
23348
|
+
{
|
|
23349
|
+
method: "POST",
|
|
23350
|
+
path: "/api/sites",
|
|
23351
|
+
summary: "Create a site",
|
|
23352
|
+
request: CreateSiteBody,
|
|
23353
|
+
response: SiteSchema,
|
|
23354
|
+
handler: handle2(async (req, { db }) => {
|
|
23355
|
+
const body = CreateSiteBody.parse(await req.json());
|
|
23356
|
+
return createSite(db, body);
|
|
23357
|
+
})
|
|
23358
|
+
},
|
|
23359
|
+
{
|
|
23360
|
+
method: "PATCH",
|
|
23361
|
+
path: "/api/site",
|
|
23362
|
+
summary: "Rename a site or change its title. Query: ?id=<id>",
|
|
23363
|
+
request: UpdateSiteBody,
|
|
23364
|
+
response: SiteSchema,
|
|
23365
|
+
handler: handle2(async (req, { db }) => {
|
|
23366
|
+
const body = UpdateSiteBody.parse(await req.json());
|
|
23367
|
+
return updateSite(db, need2(req, "id"), body);
|
|
23368
|
+
})
|
|
23369
|
+
},
|
|
23370
|
+
{
|
|
23371
|
+
method: "DELETE",
|
|
23372
|
+
path: "/api/site",
|
|
23373
|
+
summary: "Delete a site and its files. Query: ?id=<id>",
|
|
23374
|
+
response: exports_external.object({ ok: exports_external.boolean() }),
|
|
23375
|
+
handler: handle2((req, { db }) => ({ ok: deleteSite(db, need2(req, "id")) }))
|
|
23376
|
+
},
|
|
23377
|
+
{
|
|
23378
|
+
method: "POST",
|
|
23379
|
+
path: "/api/site/file",
|
|
23380
|
+
summary: "Upload/replace a file. Query: ?site=<id|name>&path=<path>; body is raw bytes",
|
|
23381
|
+
response: SiteFileSchema,
|
|
23382
|
+
handler: handle2(async (req, { db }) => {
|
|
23383
|
+
const site = resolveSite(db, need2(req, "site"));
|
|
23384
|
+
const ct3 = req.headers.get("content-type");
|
|
23385
|
+
const file2 = await putFile(db, site.id, need2(req, "path"), {
|
|
23386
|
+
data: await req.arrayBuffer(),
|
|
23387
|
+
contentType: ct3 && ct3 !== "application/octet-stream" ? ct3 : undefined
|
|
23388
|
+
});
|
|
23389
|
+
const { content: _content, ...summary } = file2;
|
|
23390
|
+
return summary;
|
|
23391
|
+
})
|
|
23392
|
+
},
|
|
23393
|
+
{
|
|
23394
|
+
method: "DELETE",
|
|
23395
|
+
path: "/api/site/file",
|
|
23396
|
+
summary: "Delete a file. Query: ?site=<id|name>&path=<path>",
|
|
23397
|
+
response: exports_external.object({ ok: exports_external.boolean() }),
|
|
23398
|
+
handler: handle2((req, { db }) => ({
|
|
23399
|
+
ok: deleteFile(db, resolveSite(db, need2(req, "site")).id, need2(req, "path"))
|
|
23400
|
+
}))
|
|
23088
23401
|
}
|
|
23089
23402
|
];
|
|
23090
23403
|
|
|
@@ -23711,7 +24024,9 @@ USAGE
|
|
|
23711
24024
|
CONCEPTS
|
|
23712
24025
|
database (db) A typed table. Owns properties and records.
|
|
23713
24026
|
property (prop) A column with a type (text|number|...). Belongs to a db.
|
|
23714
|
-
record (rec) A row
|
|
24027
|
+
record (rec) A row of cells. Write a flat { column: value } map; reads
|
|
24028
|
+
wrap them as { id, database_id, values: { column: value } }
|
|
24029
|
+
\u2014 field values live under "values", keyed by property name.
|
|
23715
24030
|
document (doc) A markdown doc, stored as ordered blocks (block-level CRDT).
|
|
23716
24031
|
site (site) A named bucket of files (HTML/CSS/JS) served at /sites/<name>/.
|
|
23717
24032
|
Every id is "<kind>_<slug>-<rand>", e.g. db_tasks-k3f9c1, rec_fix-login-7j02an.
|
|
@@ -23808,6 +24123,13 @@ AI WORKFLOW (edit a doc without clobbering concurrent changes)
|
|
|
23808
24123
|
mh doc edit <ref> --old "old text" --new "new text" --if-match <version>
|
|
23809
24124
|
mh doc append <ref> --body "a new paragraph"
|
|
23810
24125
|
|
|
24126
|
+
SITE DATA (read a table from a hosted page \u2014 field values nest under "values")
|
|
24127
|
+
// a page served at /sites/<name>/ reads this library same-origin:
|
|
24128
|
+
const rs = await fetch('/api/records?db=tasks').then(r => r.json());
|
|
24129
|
+
// rs.records: [{ id, database_id, values: { Title, Status } }]
|
|
24130
|
+
rs.records.forEach(r => render(r.values.Title, r.values.Status));
|
|
24131
|
+
// NOTE: writes take a flat { column: value } map; reads wrap cells in .values
|
|
24132
|
+
|
|
23811
24133
|
Run 'mh <command> --help' for arguments and examples of a specific command.`;
|
|
23812
24134
|
var EXAMPLES = {
|
|
23813
24135
|
init: ["mh init", "METAHUB_HOME=/tmp/mh mh init # use an alternate home"],
|
|
@@ -23991,4 +24313,4 @@ if (argv2.includes("--server")) {
|
|
|
23991
24313
|
runMain(main, { showUsage: showUsage2 });
|
|
23992
24314
|
}
|
|
23993
24315
|
|
|
23994
|
-
//# debugId=
|
|
24316
|
+
//# debugId=740ECE016A65928164756E2164756E21
|