nx-kv 0.16.0 → 0.17.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 +35 -0
- package/package.json +1 -1
- package/src/core/localatt.js +137 -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/index.js +5 -4
- package/src/modules/repos/service.js +3 -5
- package/src/modules/repos/transfer.js +5 -5
- 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 +4 -1
- 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/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 +34 -29
- 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 +40 -104
- package/src/web/public/assets/Introduction-B4k08SH9.css +1 -0
- package/src/web/public/assets/Introduction-Bbee0iwv.js +1 -0
- package/src/web/public/assets/cloud-B2DmkIpl.js +3 -0
- package/src/web/public/assets/dir-DSN1ss82.js +3 -0
- package/src/web/public/assets/group-BeYvUIyv.js +1 -0
- package/src/web/public/assets/index-CXeLbq_l.css +1 -0
- package/src/web/public/assets/index-Cz51X3aE.js +50 -0
- package/src/web/public/assets/{md-history-rfsLK89W.js → md-history-CN-7pPNs.js} +2 -2
- package/src/web/public/assets/member-picker-tkzaY0OF.js +1 -0
- package/src/web/public/assets/overview-BjqVRU69.js +1 -0
- package/src/web/public/assets/progress-CyAVplmW.js +3 -0
- package/src/web/public/assets/repos-BPj9-JSh.js +11 -0
- package/src/web/public/assets/settings-DR4KWF-R.js +1 -0
- package/src/web/public/assets/task-DN6Yf0IO.js +2 -0
- package/src/web/public/assets/templates-C2o7jy9W.js +2 -0
- package/src/web/public/assets/{upload-DLBQnUn4.js → upload-D6_jmUnY.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/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,41 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
|
|
4
4
|
|
|
5
|
+
## [0.17.0] - 2026-11-04 参考仓库/看板改独立弹窗 + 弹窗组件收敛 + 本地任务附件落本机仓
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **本地任务附件改走本机附件仓(任务 #15 验收反馈)**:面板编辑器贴图/选文件、
|
|
10
|
+
`localtask attach --path` 一律落 `~/.nx-kv/attachments/local/`(不发网,与云端
|
|
11
|
+
图床零关联)——修掉「云端不可达时界面看着有图、agent 领取却 `count: 0`」的
|
|
12
|
+
断链。领取标注本机仓优先(命中即给绝对路径,全命中零网络),云端清单(todo)
|
|
13
|
+
的附件链路保持不变。`file upload --local` 是本机仓的直入口;`/api/file/content`
|
|
14
|
+
本机仓优先、未命中再透传云端(面板预览离线可用)。
|
|
15
|
+
- **简介「补充文档」支持分类标签 tag**(任务 #27):同一份简介下的多篇补充文档
|
|
16
|
+
可以按标签分组,列表里按标签成段,不再靠标题前缀辨认。
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **参考仓库、看板管理从页面内联区块搬进独立弹窗**(筛选器右侧的两个入口,
|
|
21
|
+
虚线框 + ↗ 与筛选 chip 区分)。它们都是「隔一阵来维护一次」的元信息,平铺在
|
|
22
|
+
页面上会把开发仓库主表顶到两屏之外;主表因此重新回到首屏。
|
|
23
|
+
- 参考仓库弹窗用宽版(1180px):多列表格在 880px 里会将「绑定」列挤成一团;
|
|
24
|
+
表格自己当滚动容器,表头 sticky;名字列允许任意位置折断(带点的长标识原本
|
|
25
|
+
会冲出去盖住远程地址列)。
|
|
26
|
+
- **弹窗栈**:弹窗里再开弹窗(参考仓库 → 绑定 / 克隆)时,Esc 只关最上层;
|
|
27
|
+
导入导出全屏面板(z-30)在捕获阶段接管 Esc 并阻断,不会连带关掉下面的弹窗。
|
|
28
|
+
- 参考表格与看板行的字号提一档(td 13px、行高与留白同步),与新主表一致。
|
|
29
|
+
- **弹窗组件收敛(重构,界面行为不变)**:全站模态卡、确认/输入框、浮层菜单
|
|
30
|
+
收编到 `components/popup/`(Modal / useDialog / PopMenu / RowMenu / useDismiss),
|
|
31
|
+
轻提示收编到 `components/toast/`;此前散在各处的 `.dlg` / 两套互斥的 `.modal`
|
|
32
|
+
定义 / `.mdw-sheet` / `.gmenu` 全部合并为 `.pp-*` 一套。Esc、点外关闭、busy 禁关
|
|
33
|
+
从此只有一个实现,浏览器原生 `alert/confirm/prompt` 清零。
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **md 编辑器**:点中块时不再跳行高;占位符获得焦点即隐;标题上下留白统一
|
|
38
|
+
(任务 #31)。
|
|
39
|
+
|
|
5
40
|
## [0.16.0] - 2026-11-04 占位成员可救可清 + 仓库页可读性 + 前后端分层解耦
|
|
6
41
|
|
|
7
42
|
### 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
|
+
}
|
|
@@ -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),
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// 附件模块:用户空间里的图片 /
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// 附件模块:用户空间里的图片 / 文档等二进制原件,**双后端**:
|
|
2
|
+
// - 云端图床(dev_ctr_hello /api/v1/files*,上传 multipart,出图 /files/:fileId)
|
|
3
|
+
// —— 云端清单(todo)的附件走这边,本地镜像细节在 core/attachstore.js;
|
|
4
|
+
// - 本地附件仓(core/localatt.js)—— 本地任务(localtask)的贴图走这边,
|
|
5
|
+
// 不碰网:上传落本机盘、预览读本机文件、agent 直接拿本地路径。
|
|
6
|
+
// 本模块只做「业务编排 + 人读渲染」,网络细节全在 core/fileapi.js。
|
|
6
7
|
//
|
|
7
8
|
// 与 todo 的分工:附件**内容**归这里;附件**怎么被任务引用**归 todo
|
|
8
9
|
// (任务正文里的 ,见 modules/todo/attach.js)。
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
accessUrlOf, contentTypeOf, deleteFile, fileInfo, fileUpload, isImageName, listFiles, openFileStream,
|
|
17
18
|
} from '../../core/fileapi.js';
|
|
18
19
|
import { localDirFor, localIndex, resolveLocal, syncAttachments } from '../../core/attachstore.js';
|
|
20
|
+
import { localDir, localReadStream, localResolve, localUpload } from '../../core/localatt.js';
|
|
19
21
|
|
|
20
22
|
const fmtBytes = (n) => {
|
|
21
23
|
const v = Number(n) || 0;
|
|
@@ -65,8 +67,7 @@ export async function listFilesAction({ group, limit = 100, offset = 0 } = {}) {
|
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
export async function uploadFileAction(patch = {}) {
|
|
68
|
-
const { path: localPath, content, name, contentType, group, accessLevel = 'public' } = patch;
|
|
69
|
-
const gid = await resolveGroupId(group);
|
|
70
|
+
const { path: localPath, content, name, contentType, group, accessLevel = 'public', local: toLocal } = patch;
|
|
70
71
|
let buffer;
|
|
71
72
|
let fileName;
|
|
72
73
|
|
|
@@ -86,6 +87,26 @@ export async function uploadFileAction(patch = {}) {
|
|
|
86
87
|
throw badInput('上传需要 --path <本机文件>,或 --content <base64> --name <文件名>');
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
// 本地任务贴图 → 本地附件仓:不碰网、不解析工作空间(group 是云端图床的归属维度),
|
|
91
|
+
// 上传即已在本地,agent 直接读 localPath——这条链路与云端零关联。
|
|
92
|
+
if (toLocal) {
|
|
93
|
+
const rec = await localUpload({ name: fileName, buffer, contentType: contentType || contentTypeOf(fileName) });
|
|
94
|
+
return {
|
|
95
|
+
fileId: rec.fileId,
|
|
96
|
+
name: rec.name,
|
|
97
|
+
size: rec.size,
|
|
98
|
+
contentType: rec.contentType,
|
|
99
|
+
url: `/api/file/content/${rec.fileId}`,
|
|
100
|
+
isImage: isImageName(rec.name, rec.contentType),
|
|
101
|
+
localPath: rec.path,
|
|
102
|
+
localDir: localDir(),
|
|
103
|
+
storage: 'local',
|
|
104
|
+
markdown: snippetOf(rec.name, rec.fileId),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const cfg = await loadConfig();
|
|
109
|
+
const gid = await resolveGroupId(group);
|
|
89
110
|
const meta = await fileUpload({
|
|
90
111
|
name: fileName,
|
|
91
112
|
buffer,
|
|
@@ -93,18 +114,33 @@ export async function uploadFileAction(patch = {}) {
|
|
|
93
114
|
groupId: gid,
|
|
94
115
|
accessLevel,
|
|
95
116
|
});
|
|
96
|
-
const cfg = await loadConfig();
|
|
97
117
|
return {
|
|
98
118
|
...meta,
|
|
99
119
|
url: accessUrlOf(cfg.baseUrl, meta.fileId),
|
|
100
120
|
isImage: isImageName(fileName, meta.contentType),
|
|
101
121
|
localDir: localDirFor(gid, cfg.attachmentsDir),
|
|
122
|
+
storage: 'cloud',
|
|
102
123
|
markdown: snippetOf(fileName, meta.fileId),
|
|
103
124
|
};
|
|
104
125
|
}
|
|
105
126
|
|
|
106
127
|
export async function getFileAction(fileId, { group } = {}) {
|
|
107
128
|
if (!fileId) throw badInput('缺少 fileId');
|
|
129
|
+
// 本地仓命中就不问云端:本地任务贴图的查询零网络、离线可用。
|
|
130
|
+
// 两套 id 同构(32 位 hex),先本地后云端的顺序天然消歧。
|
|
131
|
+
const hit = await localResolve(fileId);
|
|
132
|
+
if (hit) {
|
|
133
|
+
return {
|
|
134
|
+
fileId: hit.fileId,
|
|
135
|
+
name: hit.name,
|
|
136
|
+
size: hit.size,
|
|
137
|
+
contentType: hit.contentType,
|
|
138
|
+
url: `/api/file/content/${hit.fileId}`,
|
|
139
|
+
isImage: isImageName(hit.name, hit.contentType),
|
|
140
|
+
localPath: hit.path,
|
|
141
|
+
storage: 'local',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
108
144
|
const cfg = await loadConfig();
|
|
109
145
|
const gid = await resolveGroupId(group);
|
|
110
146
|
const meta = await fileInfo(fileId, gid);
|
|
@@ -115,6 +151,7 @@ export async function getFileAction(fileId, { group } = {}) {
|
|
|
115
151
|
isImage: isImageName(meta.fileId, meta.contentType),
|
|
116
152
|
localPath: local?.path || '',
|
|
117
153
|
localName: local?.name || '',
|
|
154
|
+
storage: 'cloud',
|
|
118
155
|
};
|
|
119
156
|
}
|
|
120
157
|
|
|
@@ -138,10 +175,24 @@ export async function syncFilesAction({ group, ids, dir, prune, dryRun } = {}) {
|
|
|
138
175
|
return { ...result, usedDir: localDirFor(gid, dir || cfg.attachmentsDir) };
|
|
139
176
|
}
|
|
140
177
|
|
|
141
|
-
/** HTTP
|
|
178
|
+
/** HTTP 原始流:附件本体直接透给浏览器(面板里 <img src> 直接用)。
|
|
179
|
+
* 本地仓命中 → 读本机文件(离线可预览,本地任务贴图不依赖云端);
|
|
180
|
+
* 未命中 → 云端出图原样透传(云端清单附件的老路径,行为不变)。 */
|
|
142
181
|
export async function serveContent(ctx) {
|
|
143
182
|
const { res } = ctx.__raw || {};
|
|
144
183
|
if (!res) throw badInput('内部错误:缺少响应对象');
|
|
184
|
+
const hit = await localResolve(ctx.fileId);
|
|
185
|
+
if (hit) {
|
|
186
|
+
res.writeHead(200, { 'content-type': hit.contentType || 'application/octet-stream', 'cache-control': 'no-store' });
|
|
187
|
+
await new Promise((done, fail) => {
|
|
188
|
+
const rs = localReadStream(hit.path);
|
|
189
|
+
rs.once('error', fail);
|
|
190
|
+
res.once('close', done);
|
|
191
|
+
res.once('finish', done);
|
|
192
|
+
rs.pipe(res);
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
145
196
|
const up = await openFileStream(ctx.fileId);
|
|
146
197
|
const ct = up.headers.get('content-type') || 'application/octet-stream';
|
|
147
198
|
res.writeHead(200, { 'content-type': ct, 'cache-control': 'no-store' });
|
|
@@ -167,6 +218,14 @@ export function renderFileList(d) {
|
|
|
167
218
|
}
|
|
168
219
|
|
|
169
220
|
export function renderUpload(d) {
|
|
221
|
+
if (d.storage === 'local') {
|
|
222
|
+
return [
|
|
223
|
+
`已上传 ${d.fileId}(${fmtBytes(d.size)} · ${d.contentType})→ 本机附件仓`,
|
|
224
|
+
`本地: ${d.localPath}`,
|
|
225
|
+
`贴进任务正文(编辑时也可直接选图上传):`,
|
|
226
|
+
` ${d.isImage ? d.markdown.image : d.markdown.file}`,
|
|
227
|
+
].join('\n');
|
|
228
|
+
}
|
|
170
229
|
return [
|
|
171
230
|
`已上传 ${d.fileId}(${fmtBytes(d.size)} · ${d.contentType})`,
|
|
172
231
|
`地址: ${d.url}`,
|
|
@@ -177,6 +236,13 @@ export function renderUpload(d) {
|
|
|
177
236
|
}
|
|
178
237
|
|
|
179
238
|
export function renderFileInfo(d) {
|
|
239
|
+
if (d.storage === 'local') {
|
|
240
|
+
return [
|
|
241
|
+
`${d.fileId} · ${d.isImage ? '图片' : '文件'} · ${fmtBytes(d.size)} · ${d.contentType}`,
|
|
242
|
+
`地址: ${d.url}`,
|
|
243
|
+
`本地: ${d.localPath}(本机附件仓,不依赖云端)`,
|
|
244
|
+
].join('\n');
|
|
245
|
+
}
|
|
180
246
|
return [
|
|
181
247
|
`${d.fileId} · ${d.isImage ? '图片' : '文件'} · ${fmtBytes(d.size)} · ${d.contentType}`,
|
|
182
248
|
`地址: ${d.url}`,
|
|
@@ -355,21 +355,20 @@ export default {
|
|
|
355
355
|
: ''),
|
|
356
356
|
},
|
|
357
357
|
{
|
|
358
|
-
// 附件追加(与云端 todo attach
|
|
359
|
-
//
|
|
358
|
+
// 附件追加(与云端 todo attach 同构):--path 落**本机附件仓**(零云端)→ 嵌入正文末尾;
|
|
359
|
+
// --fileId 引用已有附件(本机仓 / 云端 id)。面板编辑器贴同一条语法(components/md/attachments.js)。
|
|
360
360
|
id: 'localtask.attach',
|
|
361
361
|
cli: ['localtask', 'attach'],
|
|
362
362
|
http: ['POST', '/api/localtasks/attach'],
|
|
363
|
-
summary: '给任务追加附件:--path
|
|
363
|
+
summary: '给任务追加附件:--path 存本机附件仓(不碰云端),或 --fileId 引用已有附件,以 markdown 嵌入正文末尾(不替换正文)',
|
|
364
364
|
flags: {
|
|
365
365
|
id: { type: 'number', required: true, hint: '任务编号' },
|
|
366
366
|
cwd: CWD,
|
|
367
367
|
key: KEY,
|
|
368
|
-
path: { type: 'string', hint: '
|
|
369
|
-
fileId: { type: 'string', hint: '
|
|
368
|
+
path: { type: 'string', hint: '本机文件路径(落 ~/.nx-kv/attachments/local/ 再嵌入)' },
|
|
369
|
+
fileId: { type: 'string', hint: '已有附件的 id(32 位 hex,可带扩展名;本机仓或云端)' },
|
|
370
370
|
name: { type: 'string', hint: '嵌入文字与本地文件名(默认取原文件名)' },
|
|
371
371
|
kind: { type: 'string', hint: 'image | file(不给按文件名/扩展名判)' },
|
|
372
|
-
accessLevel: { type: 'string', hint: 'public | private | protected(默认 public;仅 --path 上传时生效)' },
|
|
373
372
|
},
|
|
374
373
|
run: async (ctx) => {
|
|
375
374
|
const { attachToTask } = await import('../tasktool/attach.js');
|
|
@@ -381,7 +380,6 @@ export default {
|
|
|
381
380
|
fileId: ctx.fileId,
|
|
382
381
|
name: ctx.name,
|
|
383
382
|
kind: ctx.kind,
|
|
384
|
-
accessLevel: ctx.accessLevel,
|
|
385
383
|
});
|
|
386
384
|
},
|
|
387
385
|
render: (d) =>
|
|
@@ -272,7 +272,7 @@ export default {
|
|
|
272
272
|
args: ['id'],
|
|
273
273
|
run: (ctx) => listRepoDocs(ctx.id),
|
|
274
274
|
render: (d) => {
|
|
275
|
-
const one = (x, i) => ` ${i}. ${x.rel ? '自有 ' + x.rel : x.ref ? 'ref ' + x.ref + '/' + x.rel : '外部 ' + x.path}${x.note ?
|
|
275
|
+
const one = (x, i) => ` ${i}. ${x.rel ? '自有 ' + x.rel : x.ref ? 'ref ' + x.ref + '/' + x.rel : '外部 ' + x.path}${x.tag ? ` [${x.tag}]` : ''}${x.note ? ` # ${x.note}` : ''}`;
|
|
276
276
|
const lines = [`【${d.repo.name}】介绍文档 ${d.count} 篇`];
|
|
277
277
|
if (d.main) lines.push(one(d.main, 1));
|
|
278
278
|
d.links.forEach((x, i) => lines.push(one(x, i + 2)));
|
|
@@ -283,7 +283,7 @@ export default {
|
|
|
283
283
|
id: 'repo.doc.add',
|
|
284
284
|
cli: ['repo', 'doc', 'add'],
|
|
285
285
|
http: ['POST', '/api/repos/docs'],
|
|
286
|
-
summary: '给项目加一篇介绍文档:--rel 自有树路径 / --path 外部绝对路径 / --ref <参考仓库key> --file
|
|
286
|
+
summary: '给项目加一篇介绍文档:--rel 自有树路径 / --path 外部绝对路径 / --ref <参考仓库key> --file <仓库内相对路径>(同 rel/path 重复添加 = 更新)',
|
|
287
287
|
args: ['id'],
|
|
288
288
|
flags: {
|
|
289
289
|
rel: { type: 'string', hint: '自有树内相对路径(如 repo/github.com/me/x.md)' },
|
|
@@ -291,8 +291,9 @@ export default {
|
|
|
291
291
|
ref: { type: 'string', hint: '参考仓库 key(type=ref 的本机仓库)' },
|
|
292
292
|
file: { type: 'string', hint: '参考仓库内的相对路径(配合 --ref)' },
|
|
293
293
|
note: { type: 'string', hint: '这篇的说明' },
|
|
294
|
+
tag: { type: 'string', hint: '分类标签(如 qa;简介行渲染成小 chip;空串 = 清掉)' },
|
|
294
295
|
},
|
|
295
|
-
run: (ctx) => addRepoDoc(ctx.id, { rel: ctx.rel, path: ctx.path, ref: ctx.ref, file: ctx.file, note: ctx.note }),
|
|
296
|
+
run: (ctx) => addRepoDoc(ctx.id, { rel: ctx.rel, path: ctx.path, ref: ctx.ref, file: ctx.file, note: ctx.note, tag: ctx.tag }),
|
|
296
297
|
render: (d) => `已加一篇介绍文档(共 ${d.count} 篇)`,
|
|
297
298
|
},
|
|
298
299
|
{
|
|
@@ -612,7 +613,7 @@ export default {
|
|
|
612
613
|
lines.push(` ◇ 组「${b.name}」`);
|
|
613
614
|
if (b.intro && !b.intro.missing) lines.push(` 组介绍: ${b.intro.docId}`);
|
|
614
615
|
if (b.note && !b.note.missing) lines.push(` 组内描述: ${b.note.docId}`);
|
|
615
|
-
for (const l of b.docLinks) lines.push(` 绑定: ${l.docId}${l.note ? ` · ${l.note}` : ''}${l.missing ? '(缺失)' : ''}`);
|
|
616
|
+
for (const l of b.docLinks) lines.push(` 绑定: ${l.docId}${l.tag ? ` [${l.tag}]` : ''}${l.note ? ` · ${l.note}` : ''}${l.missing ? '(缺失)' : ''}`);
|
|
616
617
|
for (const x of b.externalDirs) lines.push(` 外部目录: ${x.path}(${x.files.length} 个文档,按需读盘)`);
|
|
617
618
|
}
|
|
618
619
|
if (d.refs.length) {
|
|
@@ -14,7 +14,7 @@ import fsp from 'node:fs/promises';
|
|
|
14
14
|
import { APP_DIR } from '../../core/paths.js';
|
|
15
15
|
import { openPath } from '../../core/open.js';
|
|
16
16
|
import { badInput, notFound, conflict } from '../../core/errors.js';
|
|
17
|
-
import { sanitizeDocRef } from '../docs/docref.js';
|
|
17
|
+
import { sanitizeDocRef, withLinkMeta } from '../docs/docref.js';
|
|
18
18
|
import { sanitizeGetLoop } from '../tasktool/getloop.js';
|
|
19
19
|
|
|
20
20
|
// ─── 存储(NX_KV_REPOS_STORE 可覆盖;测试隔离)──────────────────────────
|
|
@@ -94,8 +94,7 @@ function normalizeDocRefs(store) {
|
|
|
94
94
|
.map((l) => {
|
|
95
95
|
const d = sanitizeDocRef(l);
|
|
96
96
|
if (!d) return null;
|
|
97
|
-
|
|
98
|
-
return note ? { ...d, note } : d;
|
|
97
|
+
return withLinkMeta(d, l);
|
|
99
98
|
})
|
|
100
99
|
.filter(Boolean);
|
|
101
100
|
if (links.length) r.docLinks = links;
|
|
@@ -109,8 +108,7 @@ function normalizeDocRefs(store) {
|
|
|
109
108
|
.map((l) => {
|
|
110
109
|
const d = sanitizeDocRef(l);
|
|
111
110
|
if (!d) return null;
|
|
112
|
-
|
|
113
|
-
return note ? { ...d, note } : d;
|
|
111
|
+
return withLinkMeta(d, l);
|
|
114
112
|
})
|
|
115
113
|
.filter(Boolean);
|
|
116
114
|
if (links.length) b.docLinks = links;
|