@tensorix/metahub 0.1.4 → 0.1.6
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 +235 -0
- package/README.md +86 -166
- package/README.zh-CN.md +127 -0
- package/dist/cli.js +117 -67
- package/dist/cli.js.map +8 -8
- package/dist/core/blocks.d.ts +23 -4
- package/dist/core/blocks.d.ts.map +1 -1
- package/dist/core/db.d.ts +6 -0
- package/dist/core/db.d.ts.map +1 -1
- 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/sync/webui.d.ts.map +1 -1
- package/dist/index.js +120 -69
- package/dist/index.js.map +8 -8
- package/dist/webui.js +30 -31
- package/package.json +5 -2
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# metahub
|
|
2
|
+
|
|
3
|
+
**给你的 AI 一个本地知识库。**
|
|
4
|
+
|
|
5
|
+
[English](./README.md) · 简体中文
|
|
6
|
+
|
|
7
|
+
metahub 是基于 Bun + SQLite 的本地优先知识库 CLI,给 AI agent 一份持久、可同步的工作记忆:用 notion 风格的**类型化数据表**装结构化数据,用 **markdown 文档**装长文本知识,再配一套像 `Read / Edit / Write` 的编辑接口。数据全部存在你本机的 `~/.metahub/`,单机离线可用,多机之间保持一致。
|
|
8
|
+
|
|
9
|
+
> 运行时依赖 [Bun](https://bun.sh)。用 `bunx`、全局安装后的 `mh`,或下载独立二进制都行。
|
|
10
|
+
|
|
11
|
+
## 为什么用它
|
|
12
|
+
|
|
13
|
+
- **为 AI 读写而设计** —— `doc read` 先返回正文 + 版本号,`doc edit --old/--new` 做锚定查找替换、只传增量;引用一律支持 id 前缀 / 名字,不必粘完整 id。
|
|
14
|
+
- **结构化 + 非结构化一处管** —— 类型化数据表(行 / 列,8 种属性类型)放任务、流水、联系人;markdown 文档放设计稿、笔记。
|
|
15
|
+
- **本地优先、可同步** —— 数据在你自己的磁盘上,离线可用;多台机器干净合并,改不同段落互不覆盖。
|
|
16
|
+
- **自带 GUI 与 API** —— `mh --server` 一条命令起浏览器 WebUI(浏览 / 行内编辑 / 全文搜索)+ `/api/*` REST 接口 + 自动生成的 OpenAPI 文档。
|
|
17
|
+
|
|
18
|
+
<!-- TODO 截图:docs/assets/webui.png(WebUI)、docs/assets/desktop.png(桌面端) -->
|
|
19
|
+
> 想看界面?装上[桌面 App](#获取--安装),或运行 `mh --server` 后打开 `http://localhost:7777/`。
|
|
20
|
+
|
|
21
|
+
## 快速开始
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bunx @tensorix/metahub init # 创建 ~/.metahub
|
|
25
|
+
mh db create "Tasks" # 建一张表 → 返回 id,如 db_tasks-k3f9c1
|
|
26
|
+
mh use tasks # 设为「当前库」,之后 record/prop 免带库参数
|
|
27
|
+
mh prop add Title --type text
|
|
28
|
+
mh prop add Status --type select --options "todo,doing,done"
|
|
29
|
+
mh record create --data '{"Title":"写设计稿","Status":"todo"}'
|
|
30
|
+
mh doc create --title "架构说明" --body @arch.md # @file / @- / 直接字符串
|
|
31
|
+
mh search "架构" # 全文检索文档 + 记录
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
引用不必粘完整 id:凡接受 id 的地方都接受**完整 id / 唯一前缀 / 名字**,歧义时报错列候选。
|
|
35
|
+
|
|
36
|
+
**给 AI 用的非交互增量编辑**(对标 `Read / Edit / Write`):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
mh doc read <ref> # 读正文 + version(改前先读)
|
|
40
|
+
mh doc edit <ref> --old "旧文本" --new "新文本" # 锚定查找替换,只传增量
|
|
41
|
+
mh doc append <ref> --body "追加段落" # 也有 prepend
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
输出按受众自动切换:终端(人)显示表格 / markdown,被管道或子进程调用(AI)输出 **JSON**;`--json` / `--pretty` 可强制。
|
|
45
|
+
|
|
46
|
+
## 获取 / 安装
|
|
47
|
+
|
|
48
|
+
| 形态 | 安装 | 适合 |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| **桌面 App**(GUI) | `brew install --cask tensorix/tap/metahub-app` | 想要图形界面、像 notion 那样用 |
|
|
51
|
+
| **CLI** | `brew install tensorix/tap/metahub-cli`,或 `npm i -g @tensorix/metahub`,或免安装 `bunx @tensorix/metahub <cmd>` | AI agent / 命令行 |
|
|
52
|
+
| **库**(Bun) | `bun add @tensorix/metahub` | 在自己的 Bun 程序里直接调用 |
|
|
53
|
+
| **独立二进制** | 下载对应平台文件,`chmod +x` 后直接运行 | 无运行时环境 |
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// 作为库使用:所有 core 能力从包根导出
|
|
57
|
+
import { openMetahub, createDatabase, createRecord, search } from "@tensorix/metahub";
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
支持平台:`darwin-arm64` / `darwin-x64` / `linux-x64` / `linux-arm64` / `windows-x64`。库 API 详见 [CONTRIBUTING.md](./CONTRIBUTING.md)。
|
|
61
|
+
|
|
62
|
+
## 多机同步
|
|
63
|
+
|
|
64
|
+
在一台机器起服务端,另一台与它同步。文档按段落级合并,所以并发改同一篇文档的不同部分能干净合并。
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# A 机:启动同步服务端(它本身也是一个 metahub 节点)
|
|
68
|
+
mh --server --port 7777
|
|
69
|
+
|
|
70
|
+
# B 机:与服务端推 / 拉一轮
|
|
71
|
+
mh sync http://a-host:7777
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
配对一次后即可后台**周期性自动双向同步**,不必每次手敲 `mh sync`。同一条 `sync` 命令也能在「单个文档 / 数据表」与本地文件之间导入导出(文档 ↔ markdown、数据表 ↔ CSV)。原理详见[系统设计文档](./docs/system-design/)。
|
|
75
|
+
|
|
76
|
+
## WebUI、API 与 Agent 站点
|
|
77
|
+
|
|
78
|
+
`mh --server` 在根路径 `/` 内置浏览器 **WebUI**(浏览 / 行内编辑数据表、块级所见即所得编辑文档、全文搜索)。同一服务端还暴露:
|
|
79
|
+
|
|
80
|
+
- `/api/*` —— REST 接口,读写本库的数据表与文档。
|
|
81
|
+
- `/docs` —— 自动生成的 OpenAPI 文档。
|
|
82
|
+
- `/sites/<name>/` —— `mh site publish` 托管 agent 生成的 HTML/CSS/JS;页面同源调用 `/api/*` 即可读本库数据(一个本地 mini-Supabase)。
|
|
83
|
+
|
|
84
|
+
每个请求由单 token 守护(持久化在 `~/.metahub`)。服务端默认只绑 `127.0.0.1`;`--host 0.0.0.0` 才对外,此时凭据以明文 Bearer 传输,请置于可信网络或前置 TLS。细节见[系统设计文档](./docs/system-design/)。
|
|
85
|
+
|
|
86
|
+
## 命令参考
|
|
87
|
+
|
|
88
|
+
<details>
|
|
89
|
+
<summary>展开完整命令表</summary>
|
|
90
|
+
|
|
91
|
+
| 命令 | 说明 |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `mh init` | 创建 `~/.metahub` |
|
|
94
|
+
| `mh db create\|list\|get\|delete` | 管理数据库(表) |
|
|
95
|
+
| `mh use [<db>] [--clear]` | 设置/显示「当前库」(record/prop 默认作用于它) |
|
|
96
|
+
| `mh get <ref>` | 通用查找:按 id/前缀/名字解析,自动判别类型 |
|
|
97
|
+
| `mh prop add\|list\|update\|remove` | 管理属性(列);`add` 用 `--db` 指定库(默认当前库) |
|
|
98
|
+
| `mh record create\|list\|get\|update\|delete` | 管理记录(行) |
|
|
99
|
+
| `mh doc create\|list\|get\|update\|delete` | 管理 markdown 文档 |
|
|
100
|
+
| `mh doc read <id>` | 读正文 + version token(AI 改前先读) |
|
|
101
|
+
| `mh doc edit <id> --old --new` | 锚定查找替换(`--replace-all` / `--if-match`) |
|
|
102
|
+
| `mh doc append\|prepend <id> --body` | 在文档首/尾追加块 |
|
|
103
|
+
| `mh edit <id>` | 在 `$EDITOR` 中交互式编辑文档/记录(给人用) |
|
|
104
|
+
| `mh search <query>` | 全文检索(文档 + 记录) |
|
|
105
|
+
| `mh doctor` | 只读体检:列出逻辑完整性问题(孤儿引用/单元格、重复路径、文档环、重名等) |
|
|
106
|
+
| `mh repair [--dry-run]` | 确定性、幂等修复可自动修的问题(改动随 oplog 复制);`--dry-run` 仅预览(等价 doctor) |
|
|
107
|
+
| `mh site create\|put\|publish\|list\|files\|rm\|delete` | 托管 agent 生成的静态站点(HTML/CSS/JS),由 `--server` 在 `/sites/<name>/` serve 出去 |
|
|
108
|
+
| `mh token [show\|refresh]` | 查看 / 轮换持久化的服务器鉴权 token(存于 `~/.metahub`,默认 30 天到期轮换) |
|
|
109
|
+
| `mh completion <bash\|zsh\|fish>` | 打印补全脚本:`eval "$(mh completion zsh)"` |
|
|
110
|
+
| `mh sync <url>` | 与服务端同步一轮(CRDT 推/拉);`/sync` 受保护时按已存凭据直连,否则在交互终端提示输入 token 并记住(`--token` 非交互) |
|
|
111
|
+
| `mh sync <src> <dst>` | 单个文档/数据表与文件互导:文档↔markdown、数据表↔CSV;方向按参数判别(哪侧是库内实体) |
|
|
112
|
+
| `mh config` | 配置服务器与同步设备:无参进交互向导,`--flag` 直配(`--host/--port/--sync-interval/--auto-sync`) |
|
|
113
|
+
| `mh config peer code\|add\|list\|sync\|enable\|disable\|rm` | 多设备配对与管理:生成一次性配对码 / 配对 / 列出 / 立即同步 / 启停 / 移除(连带吊销签发给对方的凭据) |
|
|
114
|
+
| `mh config grant list\|revoke` | 列出 / 吊销本机签发的入站同步凭据(`revoke --token` 支持精确或前缀) |
|
|
115
|
+
| `mh --server [--port] [--host] [--debug] [--token] [--sync-interval] [--no-auto-sync]` | 启动服务端:`/sync`(主 token 或配对凭据)+ 根路径 WebUI + `/api/*` REST + `/docs`(OpenAPI)+ 静态站点 `/sites/<name>/` + token 交换 `/auth/token` + 配对 `/api/pair`;内置定时器自动同步已配对 peer |
|
|
116
|
+
|
|
117
|
+
</details>
|
|
118
|
+
|
|
119
|
+
## 文档
|
|
120
|
+
|
|
121
|
+
- [系统设计](./docs/system-design/) —— 架构、数据模型、能力清单、使用流程。
|
|
122
|
+
- [贡献指南](./CONTRIBUTING.md) —— 本地开发、构建、发布、目录结构、库 API。
|
|
123
|
+
- [桌面 App](./apps/desktop/README.md) —— Electron 壳 + Bun sidecar。
|
|
124
|
+
|
|
125
|
+
## 许可证
|
|
126
|
+
|
|
127
|
+
[AGPL-3.0-only](./LICENSE)。
|
package/dist/cli.js
CHANGED
|
@@ -891,7 +891,7 @@ function observeHlc(db, node, remote, now = Date.now()) {
|
|
|
891
891
|
}
|
|
892
892
|
|
|
893
893
|
// src/core/blocks.ts
|
|
894
|
-
function
|
|
894
|
+
function parseDocBlocks(md) {
|
|
895
895
|
if (!md)
|
|
896
896
|
return [];
|
|
897
897
|
const lines = md.replace(/\r\n?/g, `
|
|
@@ -901,24 +901,24 @@ function parseBlocks(md) {
|
|
|
901
901
|
let cur = [];
|
|
902
902
|
let fenceChar = null;
|
|
903
903
|
let fenceLen = 0;
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
cur.
|
|
907
|
-
|
|
908
|
-
cur
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
904
|
+
const closeBlock = () => {
|
|
905
|
+
if (cur.length) {
|
|
906
|
+
blocks.push({ text: cur.join(`
|
|
907
|
+
`), blankAfter: 0 });
|
|
908
|
+
cur = [];
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
const onBlank = () => {
|
|
912
|
+
closeBlock();
|
|
913
|
+
if (blocks.length)
|
|
914
|
+
blocks[blocks.length - 1].blankAfter++;
|
|
913
915
|
};
|
|
914
916
|
for (const raw of lines) {
|
|
915
917
|
if (fenceChar) {
|
|
916
918
|
cur.push(raw);
|
|
917
919
|
const m2 = raw.match(/^\s*(`{3,}|~{3,})\s*$/);
|
|
918
920
|
if (m2 && m2[1][0] === fenceChar && m2[1].length >= fenceLen) {
|
|
919
|
-
|
|
920
|
-
`));
|
|
921
|
-
cur = [];
|
|
921
|
+
closeBlock();
|
|
922
922
|
fenceChar = null;
|
|
923
923
|
fenceLen = 0;
|
|
924
924
|
}
|
|
@@ -926,28 +926,34 @@ function parseBlocks(md) {
|
|
|
926
926
|
}
|
|
927
927
|
const open = raw.match(/^\s*(`{3,}|~{3,})/);
|
|
928
928
|
if (open) {
|
|
929
|
-
|
|
929
|
+
closeBlock();
|
|
930
930
|
fenceChar = open[1][0];
|
|
931
931
|
fenceLen = open[1].length;
|
|
932
932
|
cur.push(raw);
|
|
933
933
|
} else if (raw.trim() === "") {
|
|
934
|
-
|
|
934
|
+
onBlank();
|
|
935
935
|
} else {
|
|
936
936
|
cur.push(raw);
|
|
937
937
|
}
|
|
938
938
|
}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
`));
|
|
943
|
-
} else {
|
|
944
|
-
flushPara();
|
|
945
|
-
}
|
|
939
|
+
closeBlock();
|
|
940
|
+
for (let i2 = 0;i2 < blocks.length - 1; i2++)
|
|
941
|
+
blocks[i2].blankAfter = Math.max(0, blocks[i2].blankAfter - 1);
|
|
946
942
|
return blocks;
|
|
947
943
|
}
|
|
948
|
-
function
|
|
949
|
-
|
|
950
|
-
|
|
944
|
+
function serializeDocBlocks(blocks) {
|
|
945
|
+
const live = blocks.filter((b2) => !!b2.text && b2.text.length > 0);
|
|
946
|
+
const out = [];
|
|
947
|
+
live.forEach((b2, i2) => {
|
|
948
|
+
out.push(...b2.text.split(`
|
|
949
|
+
`));
|
|
950
|
+
const extra = Math.max(0, b2.blankAfter ?? 0);
|
|
951
|
+
if (i2 < live.length - 1)
|
|
952
|
+
out.push("");
|
|
953
|
+
for (let k2 = 0;k2 < extra; k2++)
|
|
954
|
+
out.push("");
|
|
955
|
+
});
|
|
956
|
+
return out.join(`
|
|
951
957
|
`);
|
|
952
958
|
}
|
|
953
959
|
function reconcile(oldTexts, newTexts) {
|
|
@@ -1004,9 +1010,9 @@ function isBlockManaged(db, docId) {
|
|
|
1004
1010
|
return db.query("SELECT 1 AS x FROM doc_blocks WHERE doc_id = ? LIMIT 1").get(docId) != null;
|
|
1005
1011
|
}
|
|
1006
1012
|
function recomputeDocBody(db, docId) {
|
|
1007
|
-
const rows = db.query("SELECT text FROM doc_blocks WHERE doc_id = ? AND __deleted = 0 ORDER BY order_key, id").all(docId);
|
|
1013
|
+
const rows = db.query("SELECT text, blank_after FROM doc_blocks WHERE doc_id = ? AND __deleted = 0 ORDER BY order_key, id").all(docId);
|
|
1008
1014
|
ensureRow(db, "documents", docId);
|
|
1009
|
-
db.query("UPDATE documents SET body = ? WHERE id = ?").run(
|
|
1015
|
+
db.query("UPDATE documents SET body = ? WHERE id = ?").run(serializeDocBlocks(rows.map((r3) => ({ text: r3.text, blankAfter: r3.blank_after ?? 0 }))), docId);
|
|
1010
1016
|
}
|
|
1011
1017
|
function materialize(db, dataset, rowId, col, valueJson) {
|
|
1012
1018
|
if (dataset === "records") {
|
|
@@ -1096,7 +1102,7 @@ var init_crdt = __esm(() => {
|
|
|
1096
1102
|
},
|
|
1097
1103
|
doc_blocks: {
|
|
1098
1104
|
table: "doc_blocks",
|
|
1099
|
-
cols: new Set(["doc_id", "text", "order_key", "__deleted"])
|
|
1105
|
+
cols: new Set(["doc_id", "text", "order_key", "blank_after", "__deleted"])
|
|
1100
1106
|
},
|
|
1101
1107
|
sites: {
|
|
1102
1108
|
table: "sites",
|
|
@@ -1423,6 +1429,7 @@ var HTML = `<!doctype html>
|
|
|
1423
1429
|
--radius:8px; --radius-sm:6px;
|
|
1424
1430
|
--ui:"Hanken Grotesk",-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
|
|
1425
1431
|
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,monospace;
|
|
1432
|
+
--note-lh:1.6;
|
|
1426
1433
|
--code-bg:#f8f8f6;
|
|
1427
1434
|
--hl-cmt:#9a9aa2; --hl-key:#a626a4; --hl-str:#28823f; --hl-num:#b76a00;
|
|
1428
1435
|
--hl-fn:#3f51d6; --hl-type:#0a7ea4; --hl-var:#c2410c;
|
|
@@ -1455,7 +1462,7 @@ var HTML = `<!doctype html>
|
|
|
1455
1462
|
* { box-sizing: border-box; }
|
|
1456
1463
|
html, body { margin: 0; height: 100%; }
|
|
1457
1464
|
body { font-family: var(--ui); font-size: 14px; line-height: 1.55; color: var(--fg); background: var(--bg);
|
|
1458
|
-
overflow: hidden;
|
|
1465
|
+
overflow: hidden; text-rendering: auto; }
|
|
1459
1466
|
button { font: inherit; color: inherit; cursor: pointer; background: none; border: 0; padding: 0; }
|
|
1460
1467
|
input, textarea { font: inherit; color: inherit; }
|
|
1461
1468
|
.muted { color: var(--muted); }
|
|
@@ -1511,6 +1518,8 @@ var HTML = `<!doctype html>
|
|
|
1511
1518
|
.dragging { opacity:.35; }
|
|
1512
1519
|
.sb-footer { flex:none; padding:6px 8px; border-top:1px solid var(--line); }
|
|
1513
1520
|
.sb-footer .navitem { font-size:13.5px; }
|
|
1521
|
+
.nav-dot { flex:none; width:7px; height:7px; border-radius:50%; background:var(--danger);
|
|
1522
|
+
box-shadow:0 0 0 2px var(--surface); margin-right:2px; }
|
|
1514
1523
|
|
|
1515
1524
|
/* settings page */
|
|
1516
1525
|
.set-page { max-width:680px; margin:0 auto; padding:40px 28px 64px; }
|
|
@@ -1520,8 +1529,19 @@ var HTML = `<!doctype html>
|
|
|
1520
1529
|
.set-section-head { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:4px; }
|
|
1521
1530
|
.set-section-desc { color:var(--fg-soft); margin-bottom:16px; }
|
|
1522
1531
|
.set-footer { margin-top:40px; padding-top:20px; border-top:1px solid var(--line); display:flex;
|
|
1523
|
-
justify-content:center; gap:
|
|
1524
|
-
.set-footer-sep { opacity:.
|
|
1532
|
+
flex-wrap:wrap; justify-content:center; align-items:center; gap:7px; color:var(--muted); font-size:12px; }
|
|
1533
|
+
.set-footer-sep { opacity:.45; }
|
|
1534
|
+
.ver-num { font-family:var(--mono); font-size:11px; color:var(--fg-soft); letter-spacing:-.01em; }
|
|
1535
|
+
.ver-up { display:inline-flex; align-items:center; gap:6px; }
|
|
1536
|
+
.ver-up.err .ver-msg { color:var(--danger); }
|
|
1537
|
+
.ver-act { background:none; border:0; padding:0; margin:0; font:inherit; font-size:12px; line-height:1;
|
|
1538
|
+
color:var(--muted); cursor:pointer; transition:color .12s; }
|
|
1539
|
+
.ver-act:hover { color:var(--accent); }
|
|
1540
|
+
.ver-act.accent { color:var(--accent); font-weight:600; }
|
|
1541
|
+
.ver-act[aria-disabled="true"] { opacity:.6; cursor:default; pointer-events:none; }
|
|
1542
|
+
.ver-dot { width:6px; height:6px; border-radius:50%; background:var(--accent); flex:none; }
|
|
1543
|
+
.ver-dot.pulse { animation:verpulse 1.8s ease-in-out infinite; }
|
|
1544
|
+
@keyframes verpulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
|
|
1525
1545
|
.theme-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
|
|
1526
1546
|
.theme-card { position:relative; display:flex; flex-direction:column; align-items:flex-start; gap:10px;
|
|
1527
1547
|
text-align:left; padding:16px; border:1px solid var(--line-strong); border-radius:var(--radius);
|
|
@@ -1614,8 +1634,10 @@ var HTML = `<!doctype html>
|
|
|
1614
1634
|
/* The list item's host gutter controls the whole unit, so suppress the nested
|
|
1615
1635
|
code block's own gutter (it would overlap the list marker on the same row). */
|
|
1616
1636
|
.block.list-code-host + .block-wrap.nested .gutter { display:none; }
|
|
1617
|
-
.editable { outline:none; flex:1; min-width:0; padding:3px 2px; line-height:
|
|
1637
|
+
.editable { outline:none; flex:1; min-width:0; padding:3px 2px; line-height:var(--note-lh); }
|
|
1618
1638
|
.editable:empty::before { content:attr(data-ph); color:var(--muted); pointer-events:none; }
|
|
1639
|
+
/* The generic "/" prompt only shows on the focused line; idle blank lines (paragraphs and list items, any depth) stay blank. */
|
|
1640
|
+
.editable[data-ph-hint="slash"]:empty:not(:focus)::before { content:""; }
|
|
1619
1641
|
.b-h1 .editable { font-size:28px; font-weight:700; letter-spacing:-.02em; padding-top:12px; }
|
|
1620
1642
|
.b-h2 .editable { font-size:22px; font-weight:650; letter-spacing:-.01em; padding-top:8px; }
|
|
1621
1643
|
.b-h3 .editable { font-size:18px; font-weight:600; padding-top:4px; }
|
|
@@ -1667,7 +1689,8 @@ var HTML = `<!doctype html>
|
|
|
1667
1689
|
.hljs-emphasis { font-style:italic; }
|
|
1668
1690
|
.hljs-strong { font-weight:700; }
|
|
1669
1691
|
.marker { width:24px; flex:none; text-align:center; color:var(--fg-soft); user-select:none; padding-top:4px; }
|
|
1670
|
-
.b-todo .marker { padding-top:
|
|
1692
|
+
.b-todo .marker { box-sizing:content-box; padding-top:3px; height:calc(1em * var(--note-lh));
|
|
1693
|
+
display:flex; align-items:center; justify-content:center; }
|
|
1671
1694
|
.b-todo .marker input { width:15px; height:15px; accent-color:var(--accent); cursor:pointer; }
|
|
1672
1695
|
.b-divider { padding:10px 0; }
|
|
1673
1696
|
.b-divider hr { border:0; border-top:1px solid var(--line-strong); margin:0; width:100%; }
|
|
@@ -1854,14 +1877,14 @@ var HTML = `<!doctype html>
|
|
|
1854
1877
|
.optrow .x:hover { background:var(--hover-2); color:var(--danger); }
|
|
1855
1878
|
|
|
1856
1879
|
/* modal */
|
|
1857
|
-
.modal-scrim { position:fixed; inset:0; background:rgba(15,15,25,.4); z-index:100; display:
|
|
1880
|
+
.modal-scrim { position:fixed; inset:0; background:rgba(15,15,25,.4); z-index:100; display:flex; align-items:center; justify-content:center;
|
|
1858
1881
|
opacity:0; pointer-events:none; transition:opacity .15s; padding:20px; }
|
|
1859
1882
|
.modal-scrim.open { opacity:1; pointer-events:auto; }
|
|
1860
|
-
.modal { background:var(--bg); border-radius:16px; box-shadow:var(--shadow-lg); width:440px; max-width:100%; overflow:hidden; }
|
|
1883
|
+
.modal { background:var(--bg); border-radius:16px; box-shadow:var(--shadow-lg); width:440px; max-width:100%; max-height:100%; display:flex; flex-direction:column; overflow:hidden; }
|
|
1861
1884
|
.modal-head { padding:20px 22px 0; }
|
|
1862
1885
|
.modal-head h3 { margin:0; font-size:18px; font-weight:700; letter-spacing:-.01em; }
|
|
1863
1886
|
.modal-head p { margin:6px 0 0; color:var(--muted); font-size:13.5px; }
|
|
1864
|
-
.modal-body { padding:18px 22px; }
|
|
1887
|
+
.modal-body { padding:18px 22px; overflow-y:auto; }
|
|
1865
1888
|
.modal-foot { display:flex; justify-content:flex-end; gap:8px; padding:14px 22px; background:var(--surface); border-top:1px solid var(--line); }
|
|
1866
1889
|
.field-label { font-size:12px; font-weight:600; color:var(--fg-soft); margin:12px 0 5px; }
|
|
1867
1890
|
.field-label:first-child { margin-top:0; }
|
|
@@ -2080,6 +2103,17 @@ var HTML = `<!doctype html>
|
|
|
2080
2103
|
.qn-body .doc { max-width:none; margin:0; padding:14px 22px 30vh; }
|
|
2081
2104
|
.qn-body .doc-title { font-size:24px; margin-bottom:2px; }
|
|
2082
2105
|
.qn-body .doc-meta { display:none; }
|
|
2106
|
+
/* A native checkbox paints an opaque white box when unchecked, which breaks
|
|
2107
|
+
the window's vibrancy blur. Draw a custom box so the unchecked background
|
|
2108
|
+
stays transparent; checked falls back to the accent fill + checkmark. */
|
|
2109
|
+
body.quicknote .b-todo .marker input {
|
|
2110
|
+
appearance:none; -webkit-appearance:none; box-sizing:border-box;
|
|
2111
|
+
border:1.5px solid color-mix(in srgb, var(--fg-soft) 55%, transparent);
|
|
2112
|
+
border-radius:4px; background:transparent; }
|
|
2113
|
+
body.quicknote .b-todo .marker input:checked {
|
|
2114
|
+
border-color:var(--accent);
|
|
2115
|
+
background:var(--accent) center/10px no-repeat
|
|
2116
|
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12l5 5L20 7'/%3E%3C/svg%3E"); }
|
|
2083
2117
|
/* Compact menus inside the quick-note window only (dropdown + slash command
|
|
2084
2118
|
share .pop/.item/.lico with the main app; scope under body.quicknote so the
|
|
2085
2119
|
main app keeps its roomier density). */
|
|
@@ -3540,8 +3574,9 @@ async function runMain(cmd, opts = {}) {
|
|
|
3540
3574
|
// package.json
|
|
3541
3575
|
var package_default = {
|
|
3542
3576
|
name: "@tensorix/metahub",
|
|
3543
|
-
version: "0.1.
|
|
3577
|
+
version: "0.1.6",
|
|
3544
3578
|
description: "Metahub core library and CLI",
|
|
3579
|
+
license: "AGPL-3.0-only",
|
|
3545
3580
|
type: "module",
|
|
3546
3581
|
main: "./dist/index.js",
|
|
3547
3582
|
module: "./dist/index.js",
|
|
@@ -3559,7 +3594,9 @@ var package_default = {
|
|
|
3559
3594
|
},
|
|
3560
3595
|
files: [
|
|
3561
3596
|
"dist",
|
|
3562
|
-
"README.md"
|
|
3597
|
+
"README.md",
|
|
3598
|
+
"README.zh-CN.md",
|
|
3599
|
+
"LICENSE"
|
|
3563
3600
|
],
|
|
3564
3601
|
scripts: {
|
|
3565
3602
|
dev: "bun --hot src/cli/index.ts",
|
|
@@ -3692,11 +3729,12 @@ CREATE TABLE IF NOT EXISTS documents (
|
|
|
3692
3729
|
-- independent CRDT register so concurrent edits to different blocks merge.
|
|
3693
3730
|
-- order_key is a fractional index (ties broken by id) giving display order.
|
|
3694
3731
|
CREATE TABLE IF NOT EXISTS doc_blocks (
|
|
3695
|
-
id
|
|
3696
|
-
doc_id
|
|
3697
|
-
text
|
|
3698
|
-
order_key
|
|
3699
|
-
|
|
3732
|
+
id TEXT PRIMARY KEY,
|
|
3733
|
+
doc_id TEXT,
|
|
3734
|
+
text TEXT,
|
|
3735
|
+
order_key TEXT,
|
|
3736
|
+
blank_after INTEGER NOT NULL DEFAULT 0, -- extra blank lines kept after this block (user spacing)
|
|
3737
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
3700
3738
|
);
|
|
3701
3739
|
CREATE INDEX IF NOT EXISTS idx_doc_blocks_doc ON doc_blocks(doc_id);
|
|
3702
3740
|
|
|
@@ -4384,6 +4422,10 @@ function migratePeers(db) {
|
|
|
4384
4422
|
db.exec(`ALTER TABLE peers ADD COLUMN ${col} ${decl}`);
|
|
4385
4423
|
}
|
|
4386
4424
|
}
|
|
4425
|
+
function migrateDocBlocks(db) {
|
|
4426
|
+
if (!hasColumn(db, "doc_blocks", "blank_after"))
|
|
4427
|
+
db.exec("ALTER TABLE doc_blocks ADD COLUMN blank_after INTEGER NOT NULL DEFAULT 0");
|
|
4428
|
+
}
|
|
4387
4429
|
function openMetahub() {
|
|
4388
4430
|
ensureDirs();
|
|
4389
4431
|
const db = new Database(dbPath(), { create: true });
|
|
@@ -4391,6 +4433,7 @@ function openMetahub() {
|
|
|
4391
4433
|
runSchema(db);
|
|
4392
4434
|
migrateRecords(db);
|
|
4393
4435
|
migratePeers(db);
|
|
4436
|
+
migrateDocBlocks(db);
|
|
4394
4437
|
return db;
|
|
4395
4438
|
}
|
|
4396
4439
|
|
|
@@ -4562,7 +4605,7 @@ var use_default = defineCommand({
|
|
|
4562
4605
|
init_ids();
|
|
4563
4606
|
init_crdt();
|
|
4564
4607
|
function liveBlocks(db, docId) {
|
|
4565
|
-
return db.query("SELECT id, text, order_key FROM doc_blocks WHERE doc_id = ? AND __deleted = 0 ORDER BY order_key, id").all(docId);
|
|
4608
|
+
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);
|
|
4566
4609
|
}
|
|
4567
4610
|
function makeBlockId(text) {
|
|
4568
4611
|
return newId("blk", text.split(`
|
|
@@ -4572,29 +4615,37 @@ function emitBlock(db, docId, fields) {
|
|
|
4572
4615
|
const id = makeBlockId(fields.text);
|
|
4573
4616
|
emit(db, "doc_blocks", id, "doc_id", docId);
|
|
4574
4617
|
emit(db, "doc_blocks", id, "order_key", fields.order_key);
|
|
4618
|
+
if (fields.blankAfter)
|
|
4619
|
+
emit(db, "doc_blocks", id, "blank_after", fields.blankAfter);
|
|
4575
4620
|
emit(db, "doc_blocks", id, "text", fields.text);
|
|
4576
4621
|
}
|
|
4577
|
-
function insertBlocks(db, docId,
|
|
4578
|
-
const keys = keysBetween(after, before,
|
|
4579
|
-
|
|
4622
|
+
function insertBlocks(db, docId, blocks, after, before) {
|
|
4623
|
+
const keys = keysBetween(after, before, blocks.length);
|
|
4624
|
+
blocks.forEach((b2, i2) => emitBlock(db, docId, { text: b2.text, order_key: keys[i2], blankAfter: b2.blankAfter }));
|
|
4580
4625
|
}
|
|
4581
4626
|
function ensureBlocks(db, docId) {
|
|
4582
4627
|
if (db.query("SELECT 1 AS x FROM doc_blocks WHERE doc_id = ? LIMIT 1").get(docId))
|
|
4583
4628
|
return;
|
|
4584
4629
|
const row = db.query("SELECT body FROM documents WHERE id = ?").get(docId);
|
|
4585
|
-
const
|
|
4586
|
-
if (
|
|
4587
|
-
insertBlocks(db, docId,
|
|
4630
|
+
const blocks = parseDocBlocks(row?.body ?? "");
|
|
4631
|
+
if (blocks.length)
|
|
4632
|
+
insertBlocks(db, docId, blocks, null, null);
|
|
4588
4633
|
}
|
|
4589
4634
|
function reconcileBody(db, docId, body) {
|
|
4590
4635
|
const old = liveBlocks(db, docId);
|
|
4591
|
-
const
|
|
4636
|
+
const next = parseDocBlocks(body);
|
|
4637
|
+
const plan = reconcile(old.map((b2) => b2.text), next.map((b2) => b2.text));
|
|
4592
4638
|
for (const oi of plan.deleted)
|
|
4593
4639
|
emit(db, "doc_blocks", old[oi].id, "__deleted", 1);
|
|
4594
4640
|
const { items } = plan;
|
|
4595
4641
|
let i2 = 0;
|
|
4596
4642
|
while (i2 < items.length) {
|
|
4597
|
-
|
|
4643
|
+
const it = items[i2];
|
|
4644
|
+
if ("keep" in it) {
|
|
4645
|
+
const oldB = old[it.keep];
|
|
4646
|
+
const want = next[i2].blankAfter;
|
|
4647
|
+
if ((oldB.blank_after ?? 0) !== want)
|
|
4648
|
+
emit(db, "doc_blocks", oldB.id, "blank_after", want);
|
|
4598
4649
|
i2++;
|
|
4599
4650
|
continue;
|
|
4600
4651
|
}
|
|
@@ -4603,8 +4654,7 @@ function reconcileBody(db, docId, body) {
|
|
|
4603
4654
|
j++;
|
|
4604
4655
|
const left = i2 > 0 ? old[items[i2 - 1].keep].order_key : null;
|
|
4605
4656
|
const right = j < items.length ? old[items[j].keep].order_key : null;
|
|
4606
|
-
|
|
4607
|
-
insertBlocks(db, docId, texts, left, right);
|
|
4657
|
+
insertBlocks(db, docId, next.slice(i2, j), left, right);
|
|
4608
4658
|
i2 = j;
|
|
4609
4659
|
}
|
|
4610
4660
|
}
|
|
@@ -4623,9 +4673,9 @@ function createDocument(db, opts) {
|
|
|
4623
4673
|
if (opts.parent_id !== undefined)
|
|
4624
4674
|
emit(db, "documents", id, "parent_id", opts.parent_id);
|
|
4625
4675
|
if (opts.body !== undefined) {
|
|
4626
|
-
const
|
|
4627
|
-
if (
|
|
4628
|
-
insertBlocks(db, id,
|
|
4676
|
+
const blocks = parseDocBlocks(opts.body);
|
|
4677
|
+
if (blocks.length)
|
|
4678
|
+
insertBlocks(db, id, blocks, null, null);
|
|
4629
4679
|
}
|
|
4630
4680
|
return getDocument(db, id);
|
|
4631
4681
|
}
|
|
@@ -4671,7 +4721,7 @@ function editDocument(db, id, opts) {
|
|
|
4671
4721
|
throw new Error("--old must not be empty");
|
|
4672
4722
|
ensureBlocks(db, id);
|
|
4673
4723
|
const blocks = liveBlocks(db, id);
|
|
4674
|
-
const body =
|
|
4724
|
+
const body = serializeDocBlocks(blocks.map((b2) => ({ text: b2.text, blankAfter: b2.blank_after })));
|
|
4675
4725
|
const count = countOccurrences(body, opts.old);
|
|
4676
4726
|
if (count === 0)
|
|
4677
4727
|
throw new Error(`anchor not found: no match for --old`);
|
|
@@ -4698,25 +4748,25 @@ function appendDocument(db, id, body) {
|
|
|
4698
4748
|
if (!getDocument(db, id))
|
|
4699
4749
|
throw new Error(`no such document: ${id}`);
|
|
4700
4750
|
ensureBlocks(db, id);
|
|
4701
|
-
const
|
|
4702
|
-
if (
|
|
4751
|
+
const next = parseDocBlocks(body);
|
|
4752
|
+
if (next.length) {
|
|
4703
4753
|
const blocks = liveBlocks(db, id);
|
|
4704
4754
|
const last = blocks.length ? blocks[blocks.length - 1].order_key : null;
|
|
4705
|
-
insertBlocks(db, id,
|
|
4755
|
+
insertBlocks(db, id, next, last, null);
|
|
4706
4756
|
}
|
|
4707
|
-
return { id, changed:
|
|
4757
|
+
return { id, changed: next.length > 0, replaced: next.length, version: documentVersion(db, id) };
|
|
4708
4758
|
}
|
|
4709
4759
|
function prependDocument(db, id, body) {
|
|
4710
4760
|
if (!getDocument(db, id))
|
|
4711
4761
|
throw new Error(`no such document: ${id}`);
|
|
4712
4762
|
ensureBlocks(db, id);
|
|
4713
|
-
const
|
|
4714
|
-
if (
|
|
4763
|
+
const next = parseDocBlocks(body);
|
|
4764
|
+
if (next.length) {
|
|
4715
4765
|
const blocks = liveBlocks(db, id);
|
|
4716
4766
|
const first = blocks.length ? blocks[0].order_key : null;
|
|
4717
|
-
insertBlocks(db, id,
|
|
4767
|
+
insertBlocks(db, id, next, null, first);
|
|
4718
4768
|
}
|
|
4719
|
-
return { id, changed:
|
|
4769
|
+
return { id, changed: next.length > 0, replaced: next.length, version: documentVersion(db, id) };
|
|
4720
4770
|
}
|
|
4721
4771
|
function deleteDocument(db, id) {
|
|
4722
4772
|
if (!getDocument(db, id))
|
|
@@ -23922,4 +23972,4 @@ if (argv2.includes("--server")) {
|
|
|
23922
23972
|
runMain(main, { showUsage: showUsage2 });
|
|
23923
23973
|
}
|
|
23924
23974
|
|
|
23925
|
-
//# debugId=
|
|
23975
|
+
//# debugId=25AC050A4B6CCBA264756E2164756E21
|