nx-kv 0.16.0 → 0.18.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/CHANGELOG.md +81 -0
- package/package.json +1 -1
- package/src/core/localatt.js +137 -0
- package/src/modules/docs/brief.js +84 -0
- package/src/modules/docs/docref.js +17 -0
- package/src/modules/docs/index.js +5 -2
- package/src/modules/docs/knowledge.js +8 -7
- package/src/modules/docs/refs.js +18 -5
- package/src/modules/file/index.js +9 -5
- package/src/modules/file/service.js +75 -9
- package/src/modules/localtask/index.js +5 -7
- package/src/modules/repos/boards.js +63 -28
- package/src/modules/repos/index.js +8 -7
- package/src/modules/repos/service.js +50 -11
- package/src/modules/repos/transfer.js +22 -7
- package/src/modules/tasktool/attach.js +55 -46
- package/src/modules/tasktool/groupctx.js +1 -1
- package/src/web/frontend/App.jsx +14 -6
- package/src/web/frontend/components/Introduction/index.jsx +44 -13
- package/src/web/frontend/components/Introduction/style.css +11 -0
- package/src/web/frontend/components/Introduction/use-intro.js +66 -16
- package/src/web/frontend/components/board/member-picker.jsx +16 -21
- package/src/web/frontend/components/cloud/editor.jsx +1 -1
- package/src/web/frontend/components/cloud/group-popup.jsx +3 -1
- package/src/web/frontend/components/cloud/upload.js +6 -5
- package/src/web/frontend/components/md/attachments.js +12 -10
- package/src/web/frontend/components/md/md-panes.jsx +20 -3
- package/src/web/frontend/components/md/shortcut-sheet.jsx +19 -50
- package/src/web/frontend/components/migrate/dialog.jsx +51 -55
- package/src/web/frontend/components/popup/dismiss.js +32 -0
- package/src/web/frontend/components/popup/index.jsx +270 -0
- package/src/web/frontend/components/popup/style.css +64 -0
- package/src/web/frontend/components/repos/desc-modal.jsx +250 -0
- package/src/web/frontend/components/repos/transfer-panel.jsx +14 -7
- package/src/web/frontend/components/sidebar/index.jsx +4 -3
- package/src/web/frontend/components/sidebar/row-menu.jsx +11 -5
- package/src/web/frontend/components/sidebar/task-children.jsx +1 -1
- package/src/web/frontend/components/toast/index.jsx +40 -0
- package/src/web/frontend/components/toast/style.css +24 -0
- package/src/web/frontend/components/ui.jsx +11 -113
- package/src/web/frontend/main.jsx +1 -1
- package/src/web/frontend/modules/md/style.css +54 -33
- package/src/web/frontend/page/cloud/index.jsx +5 -3
- package/src/web/frontend/page/dir/index.jsx +10 -4
- package/src/web/frontend/page/dir/task.jsx +13 -3
- package/src/web/frontend/page/doc/index.jsx +2 -1
- package/src/web/frontend/page/group/index.jsx +1 -1
- package/src/web/frontend/page/overview/index.jsx +11 -2
- package/src/web/frontend/page/progress/index.jsx +14 -3
- package/src/web/frontend/page/repos/index.jsx +156 -124
- package/src/web/frontend/page/settings/index.jsx +9 -3
- package/src/web/frontend/page/templates/index.jsx +11 -3
- package/src/web/frontend/style-v2.css +7 -42
- package/src/web/frontend/style.css +182 -136
- package/src/web/public/assets/Introduction-B4k08SH9.css +1 -0
- package/src/web/public/assets/Introduction-CLTMLBZ5.js +1 -0
- package/src/web/public/assets/cloud-B9_h1Ym7.js +3 -0
- package/src/web/public/assets/dir-BLUQranN.js +3 -0
- package/src/web/public/assets/group-DIeOm80x.js +1 -0
- package/src/web/public/assets/index-DA98QEzx.css +1 -0
- package/src/web/public/assets/index-t7PgSf_t.js +50 -0
- package/src/web/public/assets/md-history-CDVA0KIl.js +5 -0
- package/src/web/public/assets/member-picker-9MAmKm9d.js +1 -0
- package/src/web/public/assets/overview-DNLRHNzP.js +1 -0
- package/src/web/public/assets/progress-C0MiaOW0.js +3 -0
- package/src/web/public/assets/repos--i4W5fzj.js +10 -0
- package/src/web/public/assets/settings-DSx2Wsto.js +1 -0
- package/src/web/public/assets/task-D2-CWZXv.js +2 -0
- package/src/web/public/assets/templates-DD6m8DT4.js +2 -0
- package/src/web/public/assets/{upload-DLBQnUn4.js → upload-BupH8dMK.js} +1 -1
- package/src/web/public/index.html +2 -3
- package/src/web/frontend/components/cloud/row-menu.jsx +0 -52
- package/src/web/frontend/components/overlay.jsx +0 -74
- package/src/web/public/assets/Introduction-DdUm5Il3.js +0 -1
- package/src/web/public/assets/Introduction-Q36q9sjA.css +0 -1
- package/src/web/public/assets/cloud-CRC00qO-.js +0 -3
- package/src/web/public/assets/dir-CrUiP4aQ.js +0 -3
- package/src/web/public/assets/group-CYVEZpI9.js +0 -1
- package/src/web/public/assets/index-Did6XKz0.js +0 -50
- package/src/web/public/assets/index-pgxfM8xe.css +0 -1
- package/src/web/public/assets/jsx-runtime-D3jfb0Ew.js +0 -1
- package/src/web/public/assets/md-history-rfsLK89W.js +0 -4
- package/src/web/public/assets/member-picker-BGUlAVgA.js +0 -1
- package/src/web/public/assets/overview-BFoqWgXk.js +0 -1
- package/src/web/public/assets/progress-Df-DZ6uP.js +0 -3
- package/src/web/public/assets/repos-Bvg8dLSB.js +0 -11
- package/src/web/public/assets/settings-DtmMN_KG.js +0 -1
- package/src/web/public/assets/task-D5YReySK.js +0 -2
- package/src/web/public/assets/templates-BLGE6arn.js +0 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,87 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
|
|
4
4
|
|
|
5
|
+
## [0.18.0] - 2026-11-04 描述绑定文档 + 描述弹窗
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **描述全部改成绑定的文档,不再是字符串**。三层描述(仓库 / 看板 / 组内成员)以前
|
|
10
|
+
各存一份字符串字段(`repo.desc`、`board.desc`、`board.repoNotes[key]`),现在正文
|
|
11
|
+
一律在自有文档树里,字符串降级为「派生摘要」:
|
|
12
|
+
|
|
13
|
+
| 描述 | 文档位置 |
|
|
14
|
+
|---|---|
|
|
15
|
+
| 仓库(项目) | `repo/<key>/index.md`(主篇)+ `docLinks` 补充篇 |
|
|
16
|
+
| 看板(领域) | `board/<组>.md` |
|
|
17
|
+
| 组内成员 | `group/<组>/<key>.md` |
|
|
18
|
+
|
|
19
|
+
- **写入入口全改为文档**:`repo add --desc` 变成「初始正文 → 写进主篇」;
|
|
20
|
+
`repo update --desc` / `board update --desc` 直接报错并指向 `docs set` / `docs bind`;
|
|
21
|
+
`board note --note` 写 `group/<组>/<key>.md` 再回写摘要。`desc` 字段只在保存文档时
|
|
22
|
+
由 `docs` 单向回写,不再是第三个真相源。
|
|
23
|
+
- **仓库列表的描述列显示文档**:读主篇正文的首段(两行截断),点一下直接开文档页签;
|
|
24
|
+
没写过时显示「未写」,点了就地新建。补充篇的数量以 `+N` 挂在后面。
|
|
25
|
+
- **编辑仓库弹窗**去掉「一句话描述」输入框,改成「项目描述(文档)」一行(显示主篇、
|
|
26
|
+
点击即开)。看板弹窗同理:编辑时只显示文档入口,新建时给的那段仍作为初始正文。
|
|
27
|
+
- **「描述」按钮改为弹窗:左清单 + 右 md 编辑器**。以前点它直接跳文档页签——为了
|
|
28
|
+
改两个字把整页换掉,看板筛选与滚动位置一起丢。现在弹窗左栏列这个仓库的全部描述
|
|
29
|
+
文档(组内描述 / 项目主篇 / 补充篇,各带一行摘要与「未写」标记),右栏是 md 编辑器
|
|
30
|
+
(实时预览 / 源码 / 阅读三态,Ctrl+S 或按钮保存)。切换条目与关窗都先把未保存的
|
|
31
|
+
改动落盘;右上「↗ 页签」保留(读全文、点双链跳转)。
|
|
32
|
+
- 存量数据用 `docs seed --apply` 一次性迁移(字符串 → 约定路径的 md,幂等;
|
|
33
|
+
实测本机 58 份:40 项目 + 17 组内 + 1 组介绍)。
|
|
34
|
+
- **仓库页头部只留可操作的东西**:删掉标题下那段「开发仓库是你在写的 / 参考仓库是
|
|
35
|
+
clone 下来读的 / 看板只是分组」的说明段落,以及紧随其后的三条图例
|
|
36
|
+
(`开发仓库 · 本机路径,不碰 git` 一类)。这两块纯解释性文字占了首屏近 100px,
|
|
37
|
+
把它们去掉后表格从第 130px 开始、首屏多出两行仓库。参考仓库与看板弹窗头部的
|
|
38
|
+
说明文字同样删掉,弹窗第一行直接是操作按钮(克隆 / 同步 / 检查 / 导入导出 /
|
|
39
|
+
存储根),按钮从行首排起而不是被挤到右侧。想解释的留在按钮 `title` 里。
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- `GET /api/repos` 每条记录多一个 `descDocs`:主篇(docId / 标题 / 摘要 / rel / 是否
|
|
44
|
+
已写)+ 补充篇列表,列表页不用再自己拼路径。
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **文档阅读态标题重复**:正文自带 `# 标题` 时不再额外渲染外层题头。此前同一行字
|
|
49
|
+
在阅读态会出现两次(标题 + 正文 h1),看着像渲染坏了。
|
|
50
|
+
|
|
51
|
+
## [0.17.0] - 2026-11-04 参考仓库/看板改独立弹窗 + 弹窗组件收敛 + 本地任务附件落本机仓
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- **本地任务附件改走本机附件仓(任务 #15 验收反馈)**:面板编辑器贴图/选文件、
|
|
56
|
+
`localtask attach --path` 一律落 `~/.nx-kv/attachments/local/`(不发网,与云端
|
|
57
|
+
图床零关联)——修掉「云端不可达时界面看着有图、agent 领取却 `count: 0`」的
|
|
58
|
+
断链。领取标注本机仓优先(命中即给绝对路径,全命中零网络),云端清单(todo)
|
|
59
|
+
的附件链路保持不变。`file upload --local` 是本机仓的直入口;`/api/file/content`
|
|
60
|
+
本机仓优先、未命中再透传云端(面板预览离线可用)。
|
|
61
|
+
- **简介「补充文档」支持分类标签 tag**(任务 #27):同一份简介下的多篇补充文档
|
|
62
|
+
可以按标签分组,列表里按标签成段,不再靠标题前缀辨认。
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- **参考仓库、看板管理从页面内联区块搬进独立弹窗**(筛选器右侧的两个入口,
|
|
67
|
+
虚线框 + ↗ 与筛选 chip 区分)。它们都是「隔一阵来维护一次」的元信息,平铺在
|
|
68
|
+
页面上会把开发仓库主表顶到两屏之外;主表因此重新回到首屏。
|
|
69
|
+
- 参考仓库弹窗用宽版(1180px):多列表格在 880px 里会将「绑定」列挤成一团;
|
|
70
|
+
表格自己当滚动容器,表头 sticky;名字列允许任意位置折断(带点的长标识原本
|
|
71
|
+
会冲出去盖住远程地址列)。
|
|
72
|
+
- **弹窗栈**:弹窗里再开弹窗(参考仓库 → 绑定 / 克隆)时,Esc 只关最上层;
|
|
73
|
+
导入导出全屏面板(z-30)在捕获阶段接管 Esc 并阻断,不会连带关掉下面的弹窗。
|
|
74
|
+
- 参考表格与看板行的字号提一档(td 13px、行高与留白同步),与新主表一致。
|
|
75
|
+
- **弹窗组件收敛(重构,界面行为不变)**:全站模态卡、确认/输入框、浮层菜单
|
|
76
|
+
收编到 `components/popup/`(Modal / useDialog / PopMenu / RowMenu / useDismiss),
|
|
77
|
+
轻提示收编到 `components/toast/`;此前散在各处的 `.dlg` / 两套互斥的 `.modal`
|
|
78
|
+
定义 / `.mdw-sheet` / `.gmenu` 全部合并为 `.pp-*` 一套。Esc、点外关闭、busy 禁关
|
|
79
|
+
从此只有一个实现,浏览器原生 `alert/confirm/prompt` 清零。
|
|
80
|
+
|
|
81
|
+
### Fixed
|
|
82
|
+
|
|
83
|
+
- **md 编辑器**:点中块时不再跳行高;占位符获得焦点即隐;标题上下留白统一
|
|
84
|
+
(任务 #31)。
|
|
85
|
+
|
|
5
86
|
## [0.16.0] - 2026-11-04 占位成员可救可清 + 仓库页可读性 + 前后端分层解耦
|
|
6
87
|
|
|
7
88
|
### Added
|
package/package.json
CHANGED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// 本地附件仓:**本地任务**(localtask 队列)的贴图/文件原件,落在本机磁盘上。
|
|
2
|
+
//
|
|
3
|
+
// 口径(任务 #15 验收反馈 2026-11-04):本地任务的贴图走本地存储,与云端图床
|
|
4
|
+
// **零关联**——上传不碰网、预览不透传、agent 领取直接读本地路径。云端清单
|
|
5
|
+
// (todo)的附件仍走云端图床(core/fileapi.js + core/attachstore.js 镜像),
|
|
6
|
+
// 两条链路互不依赖;只有「正文引用的附件 id」是同一套语法(modules/todo/attach.js)。
|
|
7
|
+
//
|
|
8
|
+
// 目录:~/.nx-kv/attachments/local/(NX_KV_ATTACHMENTS 可改根目录;与各
|
|
9
|
+
// 工作空间的数字目录天然不撞)。台账 manifest.json 形状与 attachstore 一致
|
|
10
|
+
// (files: fileId → {name,size,contentType,…}),复用同一套读写函数。
|
|
11
|
+
//
|
|
12
|
+
// 正文引用语法不变:``,fileId 同样是 32 位 hex——面板/解析端
|
|
13
|
+
// 不需要知道 id 背后是本地仓还是云端;读取时先查本地仓、未命中再走云端。
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
import fsp from 'node:fs/promises';
|
|
16
|
+
import { existsSync } from 'node:fs';
|
|
17
|
+
import { createReadStream } from 'node:fs';
|
|
18
|
+
import { dirname, join, parse as parsePath } from 'node:path';
|
|
19
|
+
import { attachmentsRootFromEnv } from './paths.js';
|
|
20
|
+
import { contentTypeOf } from './fileapi.js';
|
|
21
|
+
import {
|
|
22
|
+
localNameFor,
|
|
23
|
+
readManifest as readMirrorManifest,
|
|
24
|
+
safeFileName,
|
|
25
|
+
writeManifest as writeMirrorManifest,
|
|
26
|
+
} from './attachstore.js';
|
|
27
|
+
|
|
28
|
+
export const LOCAL_STORE_DIRNAME = 'local';
|
|
29
|
+
|
|
30
|
+
// 台账读写直接复用镜像那套(同形状:files 是 fileId → 记录)。groupId 字段
|
|
31
|
+
// 对本地仓没有意义(readManifest 缺省补 0),写入时不带即可。
|
|
32
|
+
const readManifest = (dir) => readMirrorManifest(dir);
|
|
33
|
+
const writeManifest = (dir, data) => writeMirrorManifest(dir, data);
|
|
34
|
+
|
|
35
|
+
/** 本地附件仓根目录(每个实例独立:NX_KV_ATTACHMENTS 覆盖根时测试天然隔离)。 */
|
|
36
|
+
export function localDir() {
|
|
37
|
+
return join(attachmentsRootFromEnv(), LOCAL_STORE_DIRNAME);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const nowIso = () => new Date().toISOString();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 新条目的 id:32 位 hex(16 字节随机)。与云端 fileId 同构——
|
|
44
|
+
* looksLikeFileId(modules/todo/attach.js)照常认,正文语法零分叉。
|
|
45
|
+
* 撞车概率 2^-128,先查本地仓再查云端的读取顺序天然消歧。
|
|
46
|
+
*/
|
|
47
|
+
function newFileId() {
|
|
48
|
+
return randomBytes(16).toString('hex');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 同目录不重名的落点(`名字 (2).ext`)——台账是唯一真相源,文件名只是给人看的。
|
|
52
|
+
function uniqueName(taken, name) {
|
|
53
|
+
if (!taken.has(name)) return name;
|
|
54
|
+
const { name: stem, ext } = parsePath(name);
|
|
55
|
+
let n = 1;
|
|
56
|
+
let candidate = name;
|
|
57
|
+
while (taken.has(candidate)) {
|
|
58
|
+
n += 1;
|
|
59
|
+
candidate = `${stem} (${n})${ext}`;
|
|
60
|
+
}
|
|
61
|
+
return candidate;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 上传一个附件到本地仓:落盘 + 记台账,返回元数据。
|
|
66
|
+
* 不碰网络——本机 serve 进程内一次落盘即完成。
|
|
67
|
+
*
|
|
68
|
+
* @param {{name: string, buffer: Buffer, contentType?: string}} p
|
|
69
|
+
* @returns {Promise<{fileId: string, name: string, size: number, contentType: string, path: string, storage: 'local'}>}
|
|
70
|
+
*/
|
|
71
|
+
export async function localUpload({ name, buffer, contentType = '' } = {}) {
|
|
72
|
+
if (!Buffer.isBuffer(buffer) || !buffer.length) throw new Error('上传内容为空');
|
|
73
|
+
const base = safeFileName(name);
|
|
74
|
+
if (!base || base === 'file') throw new Error('上传需要文件名');
|
|
75
|
+
const dir = localDir();
|
|
76
|
+
const man = await readManifest(dir);
|
|
77
|
+
const id = newFileId();
|
|
78
|
+
// 落盘文件名人读优先(嵌入的说明文字一路跟过来),缺扩展名按 contentType 补
|
|
79
|
+
const fileName = uniqueName(
|
|
80
|
+
new Set(Object.values(man.files).map((r) => String(r?.name || ''))),
|
|
81
|
+
localNameFor(id, base, contentType || contentTypeOf(base))
|
|
82
|
+
);
|
|
83
|
+
const abs = join(dir, fileName);
|
|
84
|
+
await fsp.mkdir(dirname(abs), { recursive: true });
|
|
85
|
+
await fsp.writeFile(abs, buffer);
|
|
86
|
+
const ct = contentType || contentTypeOf(fileName);
|
|
87
|
+
man.files[id] = { name: fileName, size: buffer.length, contentType: ct, createdAt: nowIso() };
|
|
88
|
+
man.updatedAt = nowIso();
|
|
89
|
+
await writeManifest(dir, { ...man, dir });
|
|
90
|
+
return { fileId: id, name: fileName, size: buffer.length, contentType: ct, path: abs, storage: 'local' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 台账 + 落盘文件都在 → 返回本地元数据;没传过或文件被手删 → null。 */
|
|
94
|
+
export async function localResolve(fileId) {
|
|
95
|
+
const id = String(fileId ?? '').trim();
|
|
96
|
+
if (!id) return null;
|
|
97
|
+
const dir = localDir();
|
|
98
|
+
const man = await readManifest(dir);
|
|
99
|
+
const rec = man.files[id];
|
|
100
|
+
if (!rec || !rec.name) return null;
|
|
101
|
+
const abs = join(dir, String(rec.name));
|
|
102
|
+
if (!existsSync(abs)) return null;
|
|
103
|
+
return {
|
|
104
|
+
fileId: id,
|
|
105
|
+
path: abs,
|
|
106
|
+
name: String(rec.name),
|
|
107
|
+
size: Number(rec.size) || 0,
|
|
108
|
+
contentType: String(rec.contentType || ''),
|
|
109
|
+
storage: 'local',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 本地仓全部在盘条目(id → 元数据),供领取侧批量标注。 */
|
|
114
|
+
export async function localIndex() {
|
|
115
|
+
const dir = localDir();
|
|
116
|
+
const man = await readManifest(dir);
|
|
117
|
+
const index = new Map();
|
|
118
|
+
for (const [id, rec] of Object.entries(man.files)) {
|
|
119
|
+
const abs = join(dir, String(rec?.name || ''));
|
|
120
|
+
if (rec?.name && existsSync(abs)) {
|
|
121
|
+
index.set(id, {
|
|
122
|
+
fileId: id,
|
|
123
|
+
path: abs,
|
|
124
|
+
name: String(rec.name),
|
|
125
|
+
size: Number(rec.size) || 0,
|
|
126
|
+
contentType: String(rec.contentType || ''),
|
|
127
|
+
storage: 'local',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { dir, index };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 本地文件读流(HTTP 透传给面板 <img> 用);调用方先 localResolve 确认在盘。 */
|
|
135
|
+
export function localReadStream(absPath) {
|
|
136
|
+
return createReadStream(absPath);
|
|
137
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// 「描述即文档」:把一个所有者(repo / board)的绑定文档集整理成短视图。
|
|
2
|
+
//
|
|
3
|
+
// 为什么单开一个文件:列表接口(repos list / board 投影)要给前端「这个项目的描述
|
|
4
|
+
// 是哪几篇 md」——主篇标题 + 补充篇数量。这活儿既要读盘(取标题)、又要按约定路径
|
|
5
|
+
// 推导(没绑过 doc 的项目也要能显示默认主篇),但**不能**把整棵树读一遍:
|
|
6
|
+
// 列表里只有主篇需要标题,补充篇给 rel 名就够(点开才读正文)。
|
|
7
|
+
//
|
|
8
|
+
// 依赖方向:docs 是叶子,repos/service 反向依赖它(它已经 import 了 docs/paths)。
|
|
9
|
+
// 这里要 deriveKey 所以走 reposBridge 动态拿,静态 import 会成环。
|
|
10
|
+
import * as paths from './paths.js';
|
|
11
|
+
import * as store from './store.js';
|
|
12
|
+
import { summaryFromMd } from './seed.js';
|
|
13
|
+
|
|
14
|
+
const TITLE_RE = /^#\s+(.+)$/m;
|
|
15
|
+
|
|
16
|
+
const baseNameOf = (p) => String(p || '').replace(/\\/g, '/').split('/').pop().replace(/\.md$/i, '');
|
|
17
|
+
|
|
18
|
+
/** DocRef → docId(与前端 openByDocId 同一口径:rel: / path: 前缀) */
|
|
19
|
+
export function refToDocId(ref) {
|
|
20
|
+
if (!ref) return '';
|
|
21
|
+
if (ref.rel) return 'rel:' + ref.rel;
|
|
22
|
+
if (ref.path) return 'path:' + ref.path;
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 约定主篇路径(没显式绑 doc 时的默认位置) */
|
|
27
|
+
export function defaultMainRel(owner, kind) {
|
|
28
|
+
if (kind === 'board') return paths.boardDocRel(owner.name);
|
|
29
|
+
const key = owner.key || owner.repoKey || '';
|
|
30
|
+
return key ? paths.repoDocRel(key) : '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 主篇的展示元信息:标题 + 摘要(正文首个非标题行,与 seed 同一口径)。
|
|
35
|
+
* 文件还没写过时标题退回**仓库/看板名**(key 末段往往是 remote 的仓库名,
|
|
36
|
+
* 跟用户认知里的项目名对不上),摘要空着,由前端显示「未写」。
|
|
37
|
+
*/
|
|
38
|
+
async function mainMeta(ref, fallbackRel, ownerName) {
|
|
39
|
+
// 没显式绑 doc 的项目只有约定路径——那种情况**也要读**,
|
|
40
|
+
// 否则迁过来的主篇永远显示不出摘要(doc 字段为空 ≠ 文档不存在)。
|
|
41
|
+
const rel = (ref && ref.rel) || fallbackRel;
|
|
42
|
+
if (rel) {
|
|
43
|
+
try {
|
|
44
|
+
const body = await store.readOwnDoc(rel);
|
|
45
|
+
const t = TITLE_RE.exec(body);
|
|
46
|
+
return {
|
|
47
|
+
title: (t && t[1].trim()) || ownerName || baseNameOf(rel),
|
|
48
|
+
summary: summaryFromMd(body),
|
|
49
|
+
};
|
|
50
|
+
} catch { /* 还没落盘 */ }
|
|
51
|
+
return { title: ownerName || baseNameOf(rel), summary: '' };
|
|
52
|
+
}
|
|
53
|
+
if (ref && ref.path) return { title: baseNameOf(ref.path), summary: '' };
|
|
54
|
+
return { title: ownerName || '', summary: '' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 文档集短视图。
|
|
59
|
+
* @param {object} owner repo 或 board 记录
|
|
60
|
+
* @param {'repo'|'board'} kind
|
|
61
|
+
* @returns {Promise<{main:{docId,title,rel,path,exists},links:Array<{docId,title,note,tag}>,count:number}>}
|
|
62
|
+
*/
|
|
63
|
+
export async function docSetOf(owner, kind) {
|
|
64
|
+
if (!owner) return { main: null, links: [], count: 0 };
|
|
65
|
+
const mainRef = owner.doc && (owner.doc.rel || owner.doc.path) ? owner.doc : null;
|
|
66
|
+
const rel = (mainRef && mainRef.rel) || defaultMainRel(owner, kind);
|
|
67
|
+
const exists = !!(rel && (await store.ownDocExists(rel))) || !!(mainRef && mainRef.path);
|
|
68
|
+
const meta = await mainMeta(mainRef, rel, owner.name || '');
|
|
69
|
+
const main = {
|
|
70
|
+
docId: mainRef ? refToDocId(mainRef) : rel ? 'rel:' + rel : '',
|
|
71
|
+
title: meta.title,
|
|
72
|
+
summary: meta.summary,
|
|
73
|
+
rel: (mainRef && mainRef.rel) || rel,
|
|
74
|
+
path: (mainRef && mainRef.path) || '',
|
|
75
|
+
exists,
|
|
76
|
+
};
|
|
77
|
+
const links = (Array.isArray(owner.docLinks) ? owner.docLinks : []).map((l) => ({
|
|
78
|
+
docId: refToDocId(l),
|
|
79
|
+
title: baseNameOf(l.rel || l.path),
|
|
80
|
+
note: l.note || '',
|
|
81
|
+
tag: l.tag || '',
|
|
82
|
+
}));
|
|
83
|
+
return { main, links, count: (main.title ? 1 : 0) + links.length };
|
|
84
|
+
}
|
|
@@ -50,6 +50,23 @@ export function sanitizeDocRef(raw) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* DocRef + 链接级注解(note/tag)合成列表条目。
|
|
55
|
+
*
|
|
56
|
+
* 为什么收在 DocRef 模块:docLinks 条目 = DocRef + 注解,而注解要过三道关
|
|
57
|
+
* (写入口合并 / 读归一化回贴 / 导出保留),三处口径必须一致——
|
|
58
|
+
* 实现只此一份,docs/refs 与 repos/service、transfer 都从这里拿。
|
|
59
|
+
* 空串/缺省 = 不落字段(对已有条目即「清掉」)。
|
|
60
|
+
*/
|
|
61
|
+
export function withLinkMeta(ref, raw = {}) {
|
|
62
|
+
const note = typeof raw.note === 'string' ? raw.note.trim() : '';
|
|
63
|
+
const tag = typeof raw.tag === 'string' ? raw.tag.trim() : '';
|
|
64
|
+
const entry = { ...ref };
|
|
65
|
+
if (note) entry.note = note;
|
|
66
|
+
if (tag) entry.tag = tag;
|
|
67
|
+
return entry;
|
|
68
|
+
}
|
|
69
|
+
|
|
53
70
|
// ─── docId(前后端统一寻址)────────────────────────────────────────────
|
|
54
71
|
//
|
|
55
72
|
// 自有:rel:<相对路径>(裸相对路径视为自有——人类可以直接给路径)
|
|
@@ -207,7 +207,9 @@ const renderNoteRef = (d) =>
|
|
|
207
207
|
const renderLink = (d) => {
|
|
208
208
|
if (d.removed !== undefined) return `已移除组绑定: ${d.removed}(剩 ${d.count} 条)`;
|
|
209
209
|
const lines = [`「${d.boardName}」的组绑定(${d.count} 条):`];
|
|
210
|
-
for (const l of d.links)
|
|
210
|
+
for (const l of d.links) {
|
|
211
|
+
lines.push(` ${l.rel ? 'rel:' + l.rel : 'path:' + l.path}${l.tag ? ` [${l.tag}]` : ''}${l.note ? ` · ${l.note}` : ''}`);
|
|
212
|
+
}
|
|
211
213
|
return lines.join('\n');
|
|
212
214
|
};
|
|
213
215
|
|
|
@@ -331,11 +333,12 @@ export default {
|
|
|
331
333
|
id: 'docs.link',
|
|
332
334
|
cli: ['docs', 'link'],
|
|
333
335
|
http: ['POST', '/api/docs/link'],
|
|
334
|
-
summary: '维护组的附加文档列表(--doc <自有路径|绝对路径> + --note;--remove <序号> 移除;--before <序号>
|
|
336
|
+
summary: '维护组的附加文档列表(--doc <自有路径|绝对路径> + --note/--tag;--remove <序号> 移除;--before <序号> 插位;同 doc 重复添加 = 更新)',
|
|
335
337
|
args: ['board'],
|
|
336
338
|
flags: {
|
|
337
339
|
doc: { type: 'string', hint: '自有树路径 = 自有文档;盘符/绝对路径 = 外部只读引用' },
|
|
338
340
|
note: { type: 'string', hint: '条目说明' },
|
|
341
|
+
tag: { type: 'string', hint: '分类标签(如 qa;简介行渲染成小 chip;空串 = 清掉)' },
|
|
339
342
|
remove: { type: 'number', hint: '移除第 N 条(1 起)' },
|
|
340
343
|
before: { type: 'number', hint: '插到第 N 条之前(缺省追加到末尾)' },
|
|
341
344
|
},
|
|
@@ -125,8 +125,9 @@ export async function repoKnowledge(locator, { md = false } = {}) {
|
|
|
125
125
|
const docLinks = [];
|
|
126
126
|
const externalDirs = [];
|
|
127
127
|
for (const l of Array.isArray(b.docLinks) ? b.docLinks : []) {
|
|
128
|
+
const meta = l.tag ? { tag: l.tag } : {};
|
|
128
129
|
if (l.rel) {
|
|
129
|
-
docLinks.push({ note: l.note || '', ...(await readOwn(l.rel, { warnings, label: `组绑定·${l.rel}` })) });
|
|
130
|
+
docLinks.push({ note: l.note || '', ...meta, ...(await readOwn(l.rel, { warnings, label: `组绑定·${l.rel}` })) });
|
|
130
131
|
continue;
|
|
131
132
|
}
|
|
132
133
|
const abs = l.path && store.resolveExternal(l.path, closure);
|
|
@@ -136,11 +137,11 @@ export async function repoKnowledge(locator, { md = false } = {}) {
|
|
|
136
137
|
}
|
|
137
138
|
const st = await import('node:fs/promises').then((m) => m.stat(abs).catch(() => null));
|
|
138
139
|
if (!st) {
|
|
139
|
-
docLinks.push({ note: l.note || '', missing: true, docId: paths.extDocId(abs) });
|
|
140
|
+
docLinks.push({ note: l.note || '', ...meta, missing: true, docId: paths.extDocId(abs) });
|
|
140
141
|
} else if (st.isDirectory()) {
|
|
141
|
-
externalDirs.push({ path: abs, note: l.note || '', files: await listDirMd(abs) });
|
|
142
|
+
externalDirs.push({ path: abs, note: l.note || '', ...meta, files: await listDirMd(abs) });
|
|
142
143
|
} else {
|
|
143
|
-
docLinks.push({ note: l.note || '', ...(await readExternalDoc(abs, { warnings, label: `组绑定·${l.path}` })) });
|
|
144
|
+
docLinks.push({ note: l.note || '', ...meta, ...(await readExternalDoc(abs, { warnings, label: `组绑定·${l.path}` })) });
|
|
144
145
|
}
|
|
145
146
|
}
|
|
146
147
|
boards.push({ name: b.name, key: bkey, intro: bIntro, note, docLinks, externalDirs });
|
|
@@ -250,16 +251,16 @@ export async function groupContextFor(cwd) {
|
|
|
250
251
|
);
|
|
251
252
|
peers.push({ name: mname, note: peerNote.body || (b.repoNotes || {})[mkey] || '' });
|
|
252
253
|
}
|
|
253
|
-
// 组绑定参考文件(给可读路径——agent
|
|
254
|
+
// 组绑定参考文件(给可读路径——agent 直接读盘);tag 随条目走(有才带)
|
|
254
255
|
const docLinks = [];
|
|
255
256
|
for (const l of Array.isArray(b.docLinks) ? b.docLinks : []) {
|
|
256
257
|
if (l.rel) {
|
|
257
|
-
docLinks.push({ rel: l.rel, abs: join(paths.docsRoot(), l.rel), note: l.note || '' });
|
|
258
|
+
docLinks.push({ rel: l.rel, abs: join(paths.docsRoot(), l.rel), note: l.note || '', ...(l.tag ? { tag: l.tag } : {}) });
|
|
258
259
|
continue;
|
|
259
260
|
}
|
|
260
261
|
const abs = l.path && store.resolveExternal(l.path, closure);
|
|
261
262
|
if (!abs) continue;
|
|
262
|
-
docLinks.push({ path: abs, abs, note: l.note || '' });
|
|
263
|
+
docLinks.push({ path: abs, abs, note: l.note || '', ...(l.tag ? { tag: l.tag } : {}) });
|
|
263
264
|
}
|
|
264
265
|
groups.push({
|
|
265
266
|
name: b.name,
|
package/src/modules/docs/refs.js
CHANGED
|
@@ -10,6 +10,7 @@ import * as paths from './paths.js';
|
|
|
10
10
|
import { normalizeKey } from '../../core/keys.js';
|
|
11
11
|
import { reposBridge } from './repos-bridge.js';
|
|
12
12
|
import { badInput, notFound, conflict } from '../../core/errors.js';
|
|
13
|
+
import { withLinkMeta } from './docref.js';
|
|
13
14
|
|
|
14
15
|
// 看板定位(id / key / 名称):boardKeyOf 由 bridge 注入(key 口径必须与 repos 完全一致)
|
|
15
16
|
function findBoardIn(s, idOrKey, { normalizeKey: nk, boardKeyOf }) {
|
|
@@ -95,10 +96,13 @@ export async function setNoteDocRef({ board, repo, rel, path, reset } = {}) {
|
|
|
95
96
|
* 维护组的 docLinks。
|
|
96
97
|
* doc --doc <rel|绝对路径>(盘符/绝对 → 外部,否则自有)
|
|
97
98
|
* note 条目说明
|
|
99
|
+
* tag 分类标签(如 qa——简介行的补充链接会渲染成小 chip;传空串=清掉)
|
|
98
100
|
* remove --remove <序号>(1 起)
|
|
99
101
|
* before --before <序号>(插到该条之前;缺省追加)
|
|
102
|
+
*
|
|
103
|
+
* 同 rel/path 重复添加 = 更新该条(note/tag 以本次为准)——改标签就是重加一遍。
|
|
100
104
|
*/
|
|
101
|
-
export async function setDocLink({ board, doc, note, remove, before } = {}) {
|
|
105
|
+
export async function setDocLink({ board, doc, note, tag, remove, before } = {}) {
|
|
102
106
|
if (!board) throw badInput('缺少看板(id / key / 名称)');
|
|
103
107
|
const { mutateStore, boardKeyOf } = await reposBridge();
|
|
104
108
|
return mutateStore((s) => {
|
|
@@ -124,7 +128,8 @@ export async function setDocLink({ board, doc, note, remove, before } = {}) {
|
|
|
124
128
|
? { path: String(doc).trim() }
|
|
125
129
|
: { rel: String(doc || '') }
|
|
126
130
|
);
|
|
127
|
-
|
|
131
|
+
// 条目 = DocRef + 注解(note/tag);合成逻辑收在 docref.withLinkMeta
|
|
132
|
+
const entry = withLinkMeta(ref, { note, tag });
|
|
128
133
|
const at = b.docLinks.findIndex((x) => (x.rel || '') === (entry.rel || '') && (x.path || '') === (entry.path || ''));
|
|
129
134
|
if (at >= 0) b.docLinks.splice(at, 1);
|
|
130
135
|
const want = before !== undefined ? idxOf(before) : -1;
|
|
@@ -162,18 +167,26 @@ export async function listRepoDocs(locator) {
|
|
|
162
167
|
};
|
|
163
168
|
}
|
|
164
169
|
|
|
165
|
-
/** 往项目的介绍文档集里加一篇(rel / path / ref+rel
|
|
170
|
+
/** 往项目的介绍文档集里加一篇(rel / path / ref+rel 三选一;note/tag 可选注解)。
|
|
171
|
+
*
|
|
172
|
+
* 同 rel/path 重复添加 = 更新该条(与组级 setDocLink 同一语义)——
|
|
173
|
+
* 此前是盲目 push:改个说明就得先 remove 再 add,还顺手丢掉 note
|
|
174
|
+
* (assertDocRef 只认 rel/path,注解要在这里显式贴回去)。
|
|
175
|
+
*/
|
|
166
176
|
export async function addRepoDoc(locator, raw) {
|
|
167
177
|
const { getRepo, deriveKey, mutateStore } = await reposBridge();
|
|
168
178
|
const repo = await getRepo(locator);
|
|
169
179
|
const docRef = paths.assertDocRef(raw);
|
|
180
|
+
const entry = withLinkMeta(docRef, { note: raw && raw.note, tag: raw && raw.tag });
|
|
170
181
|
return mutateStore((s) => {
|
|
171
182
|
const r = s.repos.find((x) => deriveKey(x) === deriveKey(repo));
|
|
172
183
|
if (!r) throw notFound('repo 不存在: ' + locator);
|
|
173
184
|
if (!Array.isArray(r.docLinks)) r.docLinks = [];
|
|
174
|
-
r.docLinks.
|
|
185
|
+
const at = r.docLinks.findIndex((x) => (x.rel || '') === (entry.rel || '') && (x.path || '') === (entry.path || ''));
|
|
186
|
+
if (at >= 0) r.docLinks.splice(at, 1);
|
|
187
|
+
r.docLinks.push(entry);
|
|
175
188
|
r.updatedAt = new Date().toISOString();
|
|
176
|
-
return { key: deriveKey(r), name: r.name, added:
|
|
189
|
+
return { key: deriveKey(r), name: r.name, added: entry, count: r.docLinks.length };
|
|
177
190
|
});
|
|
178
191
|
}
|
|
179
192
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 附件模块(双后端:云端图床的用户空间视图 + 本机附件仓)。
|
|
2
2
|
//
|
|
3
|
-
// 不声明 `resource
|
|
4
|
-
//
|
|
3
|
+
// 不声明 `resource`:这里的「集合」是附件存储的投影(云端图床 / 本机仓),
|
|
4
|
+
// `upload/sync` 是它的动作,硬凑 get/update/remove 五动词只会得到语义别扭的命令。
|
|
5
5
|
// 命令与路由仍然一一对应,所以「面板上能做的,CLI 都能做」依旧成立。
|
|
6
|
+
//
|
|
7
|
+
// 两条链路的分工(任务 #15 口径):云端清单(todo)附件走图床;
|
|
8
|
+
// 本地任务(localtask)贴图走 --local 本机仓——不碰网、零云端依赖。
|
|
6
9
|
import * as service from './service.js';
|
|
7
10
|
|
|
8
11
|
// 所有动作都接受 --group 覆盖当前工作空间;默认取本机配置里的当前值
|
|
@@ -33,13 +36,14 @@ export default {
|
|
|
33
36
|
id: 'file.upload',
|
|
34
37
|
cli: ['file', 'upload'],
|
|
35
38
|
http: ['POST', '/api/file'],
|
|
36
|
-
summary: '
|
|
39
|
+
summary: '上传附件(--local 存本机附件仓,本地任务贴图用;默认传当前用户空间的云端图床)',
|
|
37
40
|
flags: {
|
|
38
41
|
path: { type: 'string', hint: '本机文件路径' },
|
|
39
42
|
content: { type: 'string', hint: 'base64 内容(面板上传用,可为 data URL)' },
|
|
40
43
|
name: { type: 'string', hint: '文件名(content 上传时必填)' },
|
|
41
44
|
contentType: { type: 'string', hint: '覆盖 Content-Type(默认按扩展名猜)' },
|
|
42
|
-
|
|
45
|
+
local: { type: 'boolean', hint: '存本机附件仓(~/.nx-kv/attachments/local/),不碰云端' },
|
|
46
|
+
accessLevel: { type: 'string', enum: ['public', 'private', 'protected'], default: 'public', hint: '出图权限;public 才能被 <img> 直接预览(--local 时无意义)' },
|
|
43
47
|
group: GROUP,
|
|
44
48
|
},
|
|
45
49
|
run: (ctx) => service.uploadFileAction(ctx),
|