nx-kv 0.17.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 +46 -0
- package/package.json +1 -1
- package/src/modules/docs/brief.js +84 -0
- package/src/modules/repos/boards.js +63 -28
- package/src/modules/repos/index.js +3 -3
- package/src/modules/repos/service.js +47 -6
- package/src/modules/repos/transfer.js +17 -2
- package/src/web/frontend/components/md/md-panes.jsx +16 -2
- package/src/web/frontend/components/repos/desc-modal.jsx +250 -0
- package/src/web/frontend/page/repos/index.jsx +128 -101
- package/src/web/frontend/style.css +145 -35
- package/src/web/public/assets/{Introduction-Bbee0iwv.js → Introduction-CLTMLBZ5.js} +1 -1
- package/src/web/public/assets/{cloud-B2DmkIpl.js → cloud-B9_h1Ym7.js} +1 -1
- package/src/web/public/assets/{dir-DSN1ss82.js → dir-BLUQranN.js} +1 -1
- package/src/web/public/assets/{group-BeYvUIyv.js → group-DIeOm80x.js} +1 -1
- package/src/web/public/assets/index-DA98QEzx.css +1 -0
- package/src/web/public/assets/{index-Cz51X3aE.js → index-t7PgSf_t.js} +2 -2
- package/src/web/public/assets/md-history-CDVA0KIl.js +5 -0
- package/src/web/public/assets/{member-picker-tkzaY0OF.js → member-picker-9MAmKm9d.js} +1 -1
- package/src/web/public/assets/{overview-BjqVRU69.js → overview-DNLRHNzP.js} +1 -1
- package/src/web/public/assets/{progress-CyAVplmW.js → progress-C0MiaOW0.js} +1 -1
- package/src/web/public/assets/repos--i4W5fzj.js +10 -0
- package/src/web/public/assets/{settings-DR4KWF-R.js → settings-DSx2Wsto.js} +1 -1
- package/src/web/public/assets/{task-DN6Yf0IO.js → task-D2-CWZXv.js} +1 -1
- package/src/web/public/assets/{templates-C2o7jy9W.js → templates-DD6m8DT4.js} +1 -1
- package/src/web/public/assets/{upload-D6_jmUnY.js → upload-BupH8dMK.js} +1 -1
- package/src/web/public/index.html +2 -2
- package/src/web/public/assets/index-CXeLbq_l.css +0 -1
- package/src/web/public/assets/md-history-CN-7pPNs.js +0 -4
- package/src/web/public/assets/repos-BPj9-JSh.js +0 -11
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,52 @@
|
|
|
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
|
+
|
|
5
51
|
## [0.17.0] - 2026-11-04 参考仓库/看板改独立弹窗 + 弹窗组件收敛 + 本地任务附件落本机仓
|
|
6
52
|
|
|
7
53
|
### Added
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -123,18 +123,21 @@ export async function getBoard(idOrKey) {
|
|
|
123
123
|
export async function createBoard({ name, desc } = {}) {
|
|
124
124
|
const nm = cleanText(name);
|
|
125
125
|
if (!nm) throw badInput('看板名称不能为空');
|
|
126
|
-
|
|
127
|
-
if (s.boards.some((
|
|
128
|
-
const
|
|
126
|
+
const b = await mutateStore((s) => {
|
|
127
|
+
if (s.boards.some((x) => x.name === nm)) throw conflict('同名看板已存在: ' + nm);
|
|
128
|
+
const board = {
|
|
129
129
|
id: newId('b'),
|
|
130
130
|
name: nm,
|
|
131
131
|
desc: cleanText(desc),
|
|
132
132
|
createdAt: new Date().toISOString(),
|
|
133
133
|
repoKeys: [], // 成员按 key 存(不再用随机 id)
|
|
134
134
|
};
|
|
135
|
-
s.boards.push(
|
|
136
|
-
return { id:
|
|
135
|
+
s.boards.push(board);
|
|
136
|
+
return { id: board.id, name: board.name, desc: board.desc, key: boardKeyOf(nm), repos: [] };
|
|
137
137
|
});
|
|
138
|
+
// 描述是文档:初始描述写成组介绍的主篇(已有就不动),摘要由回写刷新
|
|
139
|
+
if (cleanText(desc)) await writeBoardDocContent({ name: nm }, desc);
|
|
140
|
+
return b;
|
|
138
141
|
}
|
|
139
142
|
|
|
140
143
|
export async function updateBoard(idOrKey, { name, desc } = {}) {
|
|
@@ -147,7 +150,13 @@ export async function updateBoard(idOrKey, { name, desc } = {}) {
|
|
|
147
150
|
throw conflict('同名看板已存在: ' + nm);
|
|
148
151
|
}
|
|
149
152
|
if (nm !== undefined) b.name = nm;
|
|
150
|
-
|
|
153
|
+
// 领域描述同样是文档:写正文用 docs set,换绑主篇用 docs bind --board
|
|
154
|
+
if (desc !== undefined) {
|
|
155
|
+
throw badInput(
|
|
156
|
+
'描述已改为文档(不再是字符串):改正文用 `docs set board/<组>.md`,' +
|
|
157
|
+
'换绑主篇用 `docs bind --board <看板> --rel <路径>`'
|
|
158
|
+
);
|
|
159
|
+
}
|
|
151
160
|
return { id: b.id, name: b.name, desc: b.desc, key: boardKeyOf(b.name) };
|
|
152
161
|
});
|
|
153
162
|
}
|
|
@@ -408,6 +417,35 @@ export async function prunePlaceholders({ board, all = false, apply = false } =
|
|
|
408
417
|
return { apply, total: items.length, items };
|
|
409
418
|
}
|
|
410
419
|
|
|
420
|
+
/**
|
|
421
|
+
* 看板的领域描述写进 board/<组>.md(文档是权威)。已有文档就不动——
|
|
422
|
+
* 重复 createBoard(导入/补建)不该把写过的组介绍冲掉。
|
|
423
|
+
*/
|
|
424
|
+
async function writeBoardDocContent(board, text) {
|
|
425
|
+
const { boardDocRel } = await import('../docs/paths.js');
|
|
426
|
+
const store = await import('../docs/store.js');
|
|
427
|
+
const { writeBackSummary } = await import('../docs/seed.js');
|
|
428
|
+
const rel = boardDocRel(board.name);
|
|
429
|
+
if (await store.ownDocExists(rel)) return rel;
|
|
430
|
+
const body = `# ${board.name}\n\n${String(text || '').trim()}\n`;
|
|
431
|
+
await store.writeOwnDoc(rel, body);
|
|
432
|
+
await writeBackSummary(rel, body);
|
|
433
|
+
return rel;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** 组内描述写进 group/<组>/<key>.md(覆盖正文;空文本 = 只留标题,摘要自然为空)。 */
|
|
437
|
+
async function writeGroupNoteDoc(info, text) {
|
|
438
|
+
const { groupNoteRel } = await import('../docs/paths.js');
|
|
439
|
+
const store = await import('../docs/store.js');
|
|
440
|
+
const { writeBackSummary } = await import('../docs/seed.js');
|
|
441
|
+
const rel = groupNoteRel(info.boardName, info.key);
|
|
442
|
+
const head = `# ${info.boardName} · ${info.name}`;
|
|
443
|
+
const body = text ? `${head}\n\n${text}\n` : `${head}\n`;
|
|
444
|
+
await store.writeOwnDoc(rel, body);
|
|
445
|
+
await writeBackSummary(rel, body); // 摘要回填 repoNotes(列表读的那份影子)
|
|
446
|
+
return rel;
|
|
447
|
+
}
|
|
448
|
+
|
|
411
449
|
/**
|
|
412
450
|
* 设置组内描述:key = 仓库(id / key / 路径 / 名称 均可),board = 看板。
|
|
413
451
|
* 只对**已在板内**的成员有意义——不是成员就显式报错,别静默挂到没人读的地方。
|
|
@@ -417,28 +455,25 @@ export async function setBoardRepoNote({ id, key, board, note } = {}) {
|
|
|
417
455
|
const locator = key || id;
|
|
418
456
|
if (!locator) throw badInput('缺少仓库 id 或 key');
|
|
419
457
|
if (!board) throw badInput('缺少看板 id');
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
note: text,
|
|
440
|
-
};
|
|
441
|
-
});
|
|
458
|
+
const s = await loadStore();
|
|
459
|
+
// 占位成员(本地未拥有)也允许写描述——描述挂在 key 上,clone 补齐后自动生效
|
|
460
|
+
const rkey = repoKeyIn(s, locator) || normalizeKey(locator);
|
|
461
|
+
const b = findBoardIn(s, board);
|
|
462
|
+
if (!b) throw notFound('看板不存在: ' + board);
|
|
463
|
+
if (!boardRepoKeys(b, s).includes(rkey)) {
|
|
464
|
+
throw conflict(`仓库「${rkey}」不在看板「${b.name}」里——组内描述只对成员有意义`);
|
|
465
|
+
}
|
|
466
|
+
const text = String(note || '').trim();
|
|
467
|
+
const info = {
|
|
468
|
+
key: rkey,
|
|
469
|
+
name: (s.repos.find((x) => deriveKey(x) === rkey) || {}).name || rkey,
|
|
470
|
+
board: b.id,
|
|
471
|
+
boardKey: boardKeyOf(b.name),
|
|
472
|
+
boardName: b.name,
|
|
473
|
+
};
|
|
474
|
+
// 描述 = 文档:写 group/<组>/<key>.md;repoNotes 只是摘要影子(由回写维护)
|
|
475
|
+
await writeGroupNoteDoc(info, text);
|
|
476
|
+
return { ...info, note: text };
|
|
442
477
|
}
|
|
443
478
|
|
|
444
479
|
// 导入:一段 JSON = 一批主题。{boards:[{name, desc?, repos:[{key|name, desc?}]}]}
|
|
@@ -212,7 +212,7 @@ export default {
|
|
|
212
212
|
flags: {
|
|
213
213
|
name: { type: 'string' },
|
|
214
214
|
icon: { type: 'string', hint: '项目图标(emoji 或单字符,≤8 字;留空 = 用默认图标)' },
|
|
215
|
-
desc: { type: 'string' },
|
|
215
|
+
desc: { type: 'string', hint: '初始描述(写成项目主篇文档;之后描述以文档为准)' },
|
|
216
216
|
notes: { type: 'string' },
|
|
217
217
|
type: { type: 'string', enum: ['dev', 'ref'], hint: 'dev=开发仓库(默认),ref=参考仓库' },
|
|
218
218
|
remote: { type: 'string', hint: '远程 git 地址(key 的来源;导出时导它)' },
|
|
@@ -231,13 +231,13 @@ export default {
|
|
|
231
231
|
cli: ['repo', 'update'],
|
|
232
232
|
http: ['PATCH', '/api/repos/:id'],
|
|
233
233
|
summary:
|
|
234
|
-
'修改仓库路径 / 名称 /
|
|
234
|
+
'修改仓库路径 / 名称 / 备注 / 类型 / 远程地址 / key;--get-loop* 配置项目级「持续 get」(waitgoal:该项目 get 无任务时阻塞等人回填 + 交付文本带继续 get 模板)。描述已改为文档,不再经这里改',
|
|
235
235
|
args: ['id'],
|
|
236
236
|
flags: {
|
|
237
237
|
path: { type: 'string' },
|
|
238
238
|
name: { type: 'string' },
|
|
239
239
|
icon: { type: 'string', hint: '项目图标(emoji 或单字符,≤8 字;留空 = 用默认图标)' },
|
|
240
|
-
desc: { type: 'string' },
|
|
240
|
+
desc: { type: 'string', hint: '(已废弃)描述是文档:改正文用 docs set,换绑用 docs bind —— 传了会报错' },
|
|
241
241
|
notes: { type: 'string' },
|
|
242
242
|
type: { type: 'string', enum: ['dev', 'ref'], hint: 'dev=开发仓库,ref=参考仓库' },
|
|
243
243
|
remote: { type: 'string', hint: '远程 git 地址' },
|
|
@@ -369,7 +369,7 @@ export function migrateStoreKeys(store) {
|
|
|
369
369
|
export async function listRepos() {
|
|
370
370
|
const s = await loadStore();
|
|
371
371
|
// docs/paths 反过来依赖本文件的 normalizeKey(动态引入避免静态环)
|
|
372
|
-
const { repoDocRel } = await import('../docs/paths.js');
|
|
372
|
+
const { repoDocRel, groupNoteRel } = await import('../docs/paths.js');
|
|
373
373
|
const boardsOf = new Map(); // repoKey -> [看板投影]
|
|
374
374
|
for (const b of s.boards) {
|
|
375
375
|
for (const rk of boardRepoKeys(b, s)) {
|
|
@@ -380,11 +380,14 @@ export async function listRepos() {
|
|
|
380
380
|
desc: b.desc || '',
|
|
381
381
|
key: boardKeyOf(b.name),
|
|
382
382
|
note: (b.repoNotes || {})[rk] || '',
|
|
383
|
+
// 组内描述也是文档:前端点「描述」直接开这篇(绑定优先,缺省约定路径)
|
|
384
|
+
noteDocRel: (b.repoDocs || {})[rk]?.rel || groupNoteRel(b.name, rk),
|
|
385
|
+
noteDocPath: (b.repoDocs || {})[rk]?.path || '',
|
|
383
386
|
});
|
|
384
387
|
}
|
|
385
388
|
}
|
|
386
|
-
|
|
387
|
-
|
|
389
|
+
const { docSetOf } = await import('../docs/brief.js');
|
|
390
|
+
const rows = s.repos.map(({ tags: _dropped, ...r }) => {
|
|
388
391
|
const rk = deriveKey(r);
|
|
389
392
|
return {
|
|
390
393
|
...r,
|
|
@@ -397,8 +400,10 @@ export async function listRepos() {
|
|
|
397
400
|
docRel: r.doc?.rel || repoDocRel(rk),
|
|
398
401
|
boards: boardsOf.get(rk) || [],
|
|
399
402
|
};
|
|
400
|
-
})
|
|
401
|
-
|
|
403
|
+
});
|
|
404
|
+
// 描述 = 绑定的文档集(主篇标题 + 补充篇),不再是 desc 字符串
|
|
405
|
+
for (const row of rows) row.descDocs = await docSetOf(row, 'repo');
|
|
406
|
+
return rows.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
402
407
|
}
|
|
403
408
|
|
|
404
409
|
export async function addRepo(input) {
|
|
@@ -414,9 +419,40 @@ export async function addRepo(input) {
|
|
|
414
419
|
if (dup) throw conflict(`该 key 已登记为「${dup.name}」(${dup.id}): ${repo.key}`);
|
|
415
420
|
s.repos.push(repo);
|
|
416
421
|
});
|
|
422
|
+
await seedRepoDoc(repo);
|
|
417
423
|
return repo;
|
|
418
424
|
}
|
|
419
425
|
|
|
426
|
+
/**
|
|
427
|
+
* 描述是文档:add 时给的 desc 只是**初始正文**,落到主篇 md(repo/<key>/index.md 的旧单文件位),
|
|
428
|
+
* 再按 md 回写摘要。文档已在就不动——老仓库重登记不该把写过的介绍冲掉。
|
|
429
|
+
* 动态 import 同 listRepos:docs 是叶子,repos 反向依赖它(静态引会成环)。
|
|
430
|
+
*/
|
|
431
|
+
async function seedRepoDoc(repo) {
|
|
432
|
+
const desc = String((repo && repo.desc) || '').trim();
|
|
433
|
+
if (!desc) return null;
|
|
434
|
+
const { repoDocRel } = await import('../docs/paths.js');
|
|
435
|
+
const store = await import('../docs/store.js');
|
|
436
|
+
const rel = repoDocRel(repo.key);
|
|
437
|
+
if (await store.ownDocExists(rel)) return null;
|
|
438
|
+
return writeRepoDocContent(repo, desc);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* 把一段文本写成仓库的主篇文档(覆盖正文,标题取仓库名)。
|
|
443
|
+
* 导入覆盖(transfer 的 overwrite 策略)走这里——描述不能再经 updateRepo 的 desc 字段。
|
|
444
|
+
*/
|
|
445
|
+
export async function writeRepoDocContent(repo, text) {
|
|
446
|
+
const { repoDocRel } = await import('../docs/paths.js');
|
|
447
|
+
const store = await import('../docs/store.js');
|
|
448
|
+
const { writeBackSummary } = await import('../docs/seed.js');
|
|
449
|
+
const rel = repoDocRel(repo.key);
|
|
450
|
+
const body = `# ${repo.name || repo.key}\n\n${String(text || '').trim()}\n`;
|
|
451
|
+
await store.writeOwnDoc(rel, body);
|
|
452
|
+
await writeBackSummary(rel, body);
|
|
453
|
+
return rel;
|
|
454
|
+
}
|
|
455
|
+
|
|
420
456
|
export async function updateRepo(id, patch) {
|
|
421
457
|
return mutateStore((s) => {
|
|
422
458
|
// 定位:id → key → 绝对路径 → 名称(与 getRepo 同一口径——
|
|
@@ -430,7 +466,12 @@ export async function updateRepo(id, patch) {
|
|
|
430
466
|
if (!r) throw notFound('repo 不存在: ' + id);
|
|
431
467
|
if (patch.name !== undefined) r.name = String(patch.name).trim();
|
|
432
468
|
if (patch.icon !== undefined) r.icon = sanitizeIcon(patch.icon);
|
|
433
|
-
if (patch.desc !== undefined)
|
|
469
|
+
if (patch.desc !== undefined) {
|
|
470
|
+
throw badInput(
|
|
471
|
+
'描述已改为文档(不再是字符串):改正文用 `docs set <rel>`,换绑主篇用 ' +
|
|
472
|
+
'`docs bind --repo <仓库> --rel <路径>`,加补充篇用 `docs link --board <看板> --doc <路径>`'
|
|
473
|
+
);
|
|
474
|
+
}
|
|
434
475
|
if (patch.notes !== undefined) r.notes = String(patch.notes);
|
|
435
476
|
if (patch.type !== undefined) {
|
|
436
477
|
const t = patch.type === 'ref' ? 'ref' : patch.type === 'dev' ? 'dev' : null;
|
|
@@ -519,12 +519,18 @@ export async function repoImport({
|
|
|
519
519
|
}
|
|
520
520
|
if (strategy === 'overwrite') {
|
|
521
521
|
const patch = {};
|
|
522
|
+
let descIn = null;
|
|
522
523
|
for (const f of item.fields) {
|
|
524
|
+
// 描述是文档(不再是字符串字段):攒下来单独写主篇,不进 updateRepo 的 patch
|
|
525
|
+
if (f.field === 'desc') { descIn = inc.desc; continue; }
|
|
523
526
|
if (f.field === 'type') patch.type = inc.type;
|
|
524
527
|
else if (f.field === 'remote') patch.remote = inc.remote || '';
|
|
525
528
|
else patch[f.field] = inc[f.field];
|
|
526
529
|
}
|
|
527
530
|
await service.updateRepo(item.local.id, patch);
|
|
531
|
+
if (descIn !== null) {
|
|
532
|
+
await service.writeRepoDocContent({ key: item.key, name: inc.name || item.key }, descIn);
|
|
533
|
+
}
|
|
528
534
|
updated.push({ key: item.key, fields: item.fields.map((f) => f.field) });
|
|
529
535
|
continue;
|
|
530
536
|
}
|
|
@@ -627,8 +633,17 @@ export async function repoImport({
|
|
|
627
633
|
.catch(() => {});
|
|
628
634
|
}
|
|
629
635
|
if (m.note) {
|
|
630
|
-
// 组内描述挂在 key
|
|
631
|
-
|
|
636
|
+
// 组内描述挂在 key 上:占位成员也保留描述,等仓库补齐后自动生效。
|
|
637
|
+
// **直接写摘要字段**,不走 setBoardRepoNote —— 后者会把这句话写进
|
|
638
|
+
// group 文档,覆盖刚从 zip 还原的正文(描述以文档为准,文字只是影子)。
|
|
639
|
+
await service
|
|
640
|
+
.mutateStore((s) => {
|
|
641
|
+
const bb = s.boards.find((x) => x.id === b.id);
|
|
642
|
+
if (!bb) return;
|
|
643
|
+
if (!bb.repoNotes) bb.repoNotes = {};
|
|
644
|
+
bb.repoNotes[m.key] = m.note;
|
|
645
|
+
})
|
|
646
|
+
.catch(() => {});
|
|
632
647
|
}
|
|
633
648
|
if (!known) {
|
|
634
649
|
unmatched.push({
|
|
@@ -36,6 +36,16 @@ import {
|
|
|
36
36
|
// 格式快捷键 → 编辑操作(预览态划选后直接套用)
|
|
37
37
|
const FMT_KEYS = { b: 'bold', i: 'italic', e: 'code', k: 'link' };
|
|
38
38
|
|
|
39
|
+
/** 正文里已经自带 `# 标题` 了吗(跳过 frontmatter)——阅读态据此决定要不要再画题头 */
|
|
40
|
+
function bodyHasH1(body) {
|
|
41
|
+
let t = String(body || '').replace(/^\uFEFF/, '').trimStart();
|
|
42
|
+
if (t.startsWith('---')) {
|
|
43
|
+
const end = t.indexOf('\n---', 3);
|
|
44
|
+
if (end > 0) t = t.slice(end + 4).trimStart();
|
|
45
|
+
}
|
|
46
|
+
return /^#[ \t]+\S/.test(t);
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
function autoSize(el) {
|
|
40
50
|
if (!el) return;
|
|
41
51
|
el.style.height = '0px';
|
|
@@ -1254,8 +1264,12 @@ export const MdPanes = forwardRef(function MdPanes(
|
|
|
1254
1264
|
{mode === 'preview' && (
|
|
1255
1265
|
<div className="mdw-pane mdw-preview">
|
|
1256
1266
|
<div className="mdw-reader">
|
|
1257
|
-
{/* 阅读态此前不渲染标题,与 live
|
|
1258
|
-
|
|
1267
|
+
{/* 阅读态此前不渲染标题,与 live/源码两态断裂;题头与正文同轴同宽。
|
|
1268
|
+
正文自带 `# 标题` 时不再画外部题头——文档的 body 本来就含首行标题,
|
|
1269
|
+
两个都画就是同一行字出现两次(阅读态看起来像渲染 bug)。 */}
|
|
1270
|
+
{title && !bodyHasH1(body) ? (
|
|
1271
|
+
<h1 className="mdw-reader-title">{title}</h1>
|
|
1272
|
+
) : null}
|
|
1259
1273
|
<article ref={articleRef} dangerouslySetInnerHTML={{ __html: previewHtml }} />
|
|
1260
1274
|
</div>
|
|
1261
1275
|
</div>
|