nx-sk 0.9.2 → 0.11.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 +137 -0
- package/README.md +53 -4
- package/assets/nx-sk/SKILL.md +16 -2
- package/assets/nx-sk/references/10-sections-entries.md +12 -2
- package/assets/nx-sk/references/50-deploy.md +129 -0
- package/assets/nx-sk/references/60-transfer.md +75 -0
- package/package.json +1 -1
- package/src/core/fields.js +117 -3
- package/src/core/mdimages.js +65 -0
- package/src/core/zip.js +198 -0
- package/src/modules/entries/service.js +45 -22
- package/src/modules/entries/view.jsx +220 -7
- package/src/modules/export/view.jsx +4 -1
- package/src/modules/files/index.js +2 -0
- package/src/modules/files/service.js +10 -3
- package/src/modules/files/view.jsx +27 -1
- package/src/modules/sections/index.js +3 -3
- package/src/modules/sections/service.js +34 -7
- package/src/modules/sections/view.jsx +129 -11
- package/src/modules/settings/navManager.jsx +178 -0
- package/src/modules/settings/service.js +105 -10
- package/src/modules/settings/view.jsx +117 -158
- package/src/modules/system/service.js +7 -1
- package/src/modules/transfer/index.js +106 -0
- package/src/modules/transfer/service.js +778 -0
- package/src/runtime/api.js +30 -0
- package/src/runtime/registry.js +2 -1
- package/src/web/frontend/App.jsx +31 -23
- package/src/web/frontend/activeView.js +42 -0
- package/src/web/frontend/api/files.js +22 -1
- package/src/web/frontend/api/zip.js +22 -0
- package/src/web/frontend/components/fieldEditor.jsx +338 -11
- package/src/web/frontend/components/lightbox.jsx +96 -0
- package/src/web/frontend/components/navModel.js +226 -0
- package/src/web/frontend/components/transferPanel.jsx +294 -0
- package/src/web/frontend/mdInline.js +114 -0
- package/src/web/frontend/store.jsx +17 -3
- package/src/web/frontend/styles.css +232 -0
- package/src/web/public/assets/files-DuwKZ5bu.js +1 -0
- package/src/web/public/assets/{index-lmztA3cP.css → index-C8-8IXxK.css} +1 -1
- package/src/web/public/assets/index-CyHzNRPd.js +41 -0
- package/src/web/public/assets/view-B2C1AKyl.js +7 -0
- package/src/web/public/assets/view-B3dDfoL5.js +1 -0
- package/src/web/public/assets/view-BDMftvuZ.js +7 -0
- package/src/web/public/assets/view-B_XDtZn-.js +1 -0
- package/src/web/public/assets/view-CC3A5XfV.js +1 -0
- package/src/web/public/assets/{view-DAeNDUhQ.js → view-MdIYFfFy.js} +1 -1
- package/src/web/public/assets/zip-CS8JXp8H.js +1 -0
- package/src/web/public/index.html +2 -2
- package/src/web/public/assets/files-CJbQeKB4.js +0 -1
- package/src/web/public/assets/index-VCOWbzUk.js +0 -41
- package/src/web/public/assets/view-BaPkKHfW.js +0 -7
- package/src/web/public/assets/view-C3O-ELnK.js +0 -1
- package/src/web/public/assets/view-CEbJvK5c.js +0 -1
- package/src/web/public/assets/view-CyM1i0hc.js +0 -7
- package/src/web/public/assets/view-e9FlSTod.js +0 -1
package/src/core/fields.js
CHANGED
|
@@ -3,7 +3,27 @@
|
|
|
3
3
|
//
|
|
4
4
|
// type 取值:text | textarea | number | date | month | bool | select | tags | secret | file
|
|
5
5
|
|
|
6
|
-
export const FIELD_TYPES = ['text', 'textarea', 'number', 'date', 'month', 'bool', 'select', 'tags', 'secret', 'file'];
|
|
6
|
+
export const FIELD_TYPES = ['text', 'textarea', 'number', 'date', 'month', 'bool', 'select', 'tags', 'secret', 'file', 'md'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 类型的人读标签 —— 只用于**面板下拉框**。
|
|
10
|
+
*
|
|
11
|
+
* 下拉框此前直接渲染 `md` / `tags` 这种内部 id,选类型的人得先知道内部分类。
|
|
12
|
+
* 这里给中文名,渲染成「图文(可贴图)(md)」:既看得懂,也不丢 id(排查时用得上)。
|
|
13
|
+
*/
|
|
14
|
+
export const FIELD_TYPE_LABELS = {
|
|
15
|
+
text: '单行文本',
|
|
16
|
+
textarea: '多行文本',
|
|
17
|
+
number: '数字',
|
|
18
|
+
date: '日期',
|
|
19
|
+
month: '月份',
|
|
20
|
+
bool: '是/否',
|
|
21
|
+
select: '单选',
|
|
22
|
+
tags: '多值标签',
|
|
23
|
+
secret: '密文',
|
|
24
|
+
file: '附件',
|
|
25
|
+
md: '图文(可贴图)',
|
|
26
|
+
};
|
|
7
27
|
|
|
8
28
|
/**
|
|
9
29
|
* 字段的**填写策略**——回答「这个字段值不值得填」,与 type 正交。
|
|
@@ -176,6 +196,45 @@ const CAMPUS_FIELDS = [
|
|
|
176
196
|
f('duties', '职责描述', 'textarea', 'main'),
|
|
177
197
|
];
|
|
178
198
|
|
|
199
|
+
// —— 部署配置(Docker / K8s)——
|
|
200
|
+
//
|
|
201
|
+
// 一个条目 = **一份可复用的配置文件**。设计目标不是"存文件"(附件就能存),
|
|
202
|
+
// 而是"下次能在别的项目里照着用":所以除了正文,还要有「放哪」「哪些值要替换」
|
|
203
|
+
// 「什么场景用」。缺了后三项,正文就只是一段没有上下文的 YAML。
|
|
204
|
+
//
|
|
205
|
+
// 明文密钥不进这里:`content` 里只写 `${VAR}` 占位,真值放「密钥」栏目
|
|
206
|
+
// (那里是密文落盘)。这条写进字段 hint,而不是靠人自觉 —— 配置文件是最容易
|
|
207
|
+
// 被整份复制粘贴分享出去的东西。
|
|
208
|
+
const DEPLOY_FIELDS = [
|
|
209
|
+
f('name', '资源名', 'text', 'main', { hint: '一眼能看出是什么,如「node-web 多阶段 Dockerfile」' }),
|
|
210
|
+
f('kind', '资源类型', 'select', 'main', {
|
|
211
|
+
options: [
|
|
212
|
+
'Dockerfile', 'docker-compose', 'dockerignore',
|
|
213
|
+
'k8s-deployment', 'k8s-service', 'k8s-ingress', 'k8s-configmap', 'k8s-secret', 'k8s-kustomization',
|
|
214
|
+
'helm', '其他',
|
|
215
|
+
],
|
|
216
|
+
}),
|
|
217
|
+
f('stack', '适用技术栈', 'text', 'main', { fill: 'optional', hint: 'node / go / python / 任意' }),
|
|
218
|
+
f('path', '建议落盘路径', 'text', 'main', { fill: 'optional', hint: '如 deploy/Dockerfile、deploy/k8s/base/deployment.yaml' }),
|
|
219
|
+
f('tags', '标签', 'tags', 'main', { fill: 'optional', hint: '如 prod、多阶段、健康检查、SSE' }),
|
|
220
|
+
f('content', '文件正文', 'textarea', 'body', {
|
|
221
|
+
hint: '整份文件原文。密钥一律写 ${VAR} 占位,真值放「密钥」栏目,别写明文',
|
|
222
|
+
}),
|
|
223
|
+
f('vars', '需替换的变量', 'textarea', 'body', {
|
|
224
|
+
fill: 'optional',
|
|
225
|
+
hint: '每行一个「变量 = 说明」。复用前先替换这些,别指望正文里的值直接能用',
|
|
226
|
+
}),
|
|
227
|
+
f('usage', '何时用 / 关键取舍', 'textarea', 'use', {
|
|
228
|
+
fill: 'optional',
|
|
229
|
+
hint: '为什么这么写、坑在哪(例如 ingress 关 buffering 才能保住 SSE)',
|
|
230
|
+
}),
|
|
231
|
+
f('sop', '操作步骤(图文)', 'md', 'use', {
|
|
232
|
+
fill: 'optional',
|
|
233
|
+
hint: '截屏 + 说明混排:直接 Ctrl+V 粘图,图片会统一列在下方,点开可放大看细节',
|
|
234
|
+
}),
|
|
235
|
+
f('files', '原件附件', 'file', 'use', { fill: 'optional', hint: '要连真实文件一起留档时挂这里(可选)' }),
|
|
236
|
+
];
|
|
237
|
+
|
|
179
238
|
const TEMPLATES = {
|
|
180
239
|
job: {
|
|
181
240
|
id: 'job',
|
|
@@ -237,6 +296,21 @@ const TEMPLATES = {
|
|
|
237
296
|
groups: [{ id: 'main', title: '职务信息' }],
|
|
238
297
|
fields: CAMPUS_FIELDS,
|
|
239
298
|
},
|
|
299
|
+
deploy: {
|
|
300
|
+
id: 'deploy',
|
|
301
|
+
label: '部署配置(Docker / K8s)',
|
|
302
|
+
description:
|
|
303
|
+
'一份配置文件一个条目:正文即文件原文,配「放哪」「哪些值要替换」「什么场景用」,' +
|
|
304
|
+
'好让它在别的项目里能照着用。明文密钥不放这里(用 ${VAR} 占位,真值进「密钥」栏目)。',
|
|
305
|
+
titleField: 'name',
|
|
306
|
+
titleLabel: '资源名',
|
|
307
|
+
groups: [
|
|
308
|
+
{ id: 'main', title: '基本信息' },
|
|
309
|
+
{ id: 'body', title: '文件内容' },
|
|
310
|
+
{ id: 'use', title: '使用说明' },
|
|
311
|
+
],
|
|
312
|
+
fields: DEPLOY_FIELDS,
|
|
313
|
+
},
|
|
240
314
|
};
|
|
241
315
|
|
|
242
316
|
export function templateIds() {
|
|
@@ -286,6 +360,7 @@ export function seedSections(stamp) {
|
|
|
286
360
|
{ ...instantiateTemplate('internships', { id: 'internships', title: '实习/工作经历', order: 12 }), createdAt: stamp, updatedAt: stamp },
|
|
287
361
|
{ ...instantiateTemplate('campus', { id: 'campus', title: '校内职务/实践', order: 13 }), createdAt: stamp, updatedAt: stamp },
|
|
288
362
|
{ ...instantiateTemplate('secret', { id: 'secret', title: '密钥', order: 20 }), createdAt: stamp, updatedAt: stamp },
|
|
363
|
+
{ ...instantiateTemplate('deploy', { id: 'deploy', title: '部署配置', order: 30 }), createdAt: stamp, updatedAt: stamp },
|
|
289
364
|
];
|
|
290
365
|
}
|
|
291
366
|
|
|
@@ -320,6 +395,34 @@ export function syncSectionGroups(groups, fields) {
|
|
|
320
395
|
return out;
|
|
321
396
|
}
|
|
322
397
|
|
|
398
|
+
/**
|
|
399
|
+
* 同一 key 的字段在「栏目 vs 模板」之间哪些属性不同;空数组 = 定义一致。
|
|
400
|
+
*
|
|
401
|
+
* 比较前先**归一**:`fill` 缺省就是 `normal`(`validateFieldDef` 只在非 normal 时落盘),
|
|
402
|
+
* `options`/`maxItems` 缺失与空数组/空值等价。不归一的话,一份「模板里没写 fill、
|
|
403
|
+
* 栏目里写了 fill:normal」的等价定义会被报成漂移,噪音会淹没真正的差异。
|
|
404
|
+
*/
|
|
405
|
+
const FIELD_CMP_KEYS = ['label', 'type', 'group', 'hint', 'options', 'maxItems', 'sensitive', 'fill'];
|
|
406
|
+
|
|
407
|
+
function normalizedFieldDef(f) {
|
|
408
|
+
return {
|
|
409
|
+
label: String(f?.label ?? f?.key ?? ''),
|
|
410
|
+
type: f?.type || 'text',
|
|
411
|
+
group: f?.group || 'other',
|
|
412
|
+
hint: f?.hint ? String(f.hint) : '',
|
|
413
|
+
options: Array.isArray(f?.options) ? f.options.map(String) : [],
|
|
414
|
+
maxItems: Number.isFinite(f?.maxItems) ? f.maxItems : null,
|
|
415
|
+
sensitive: f?.sensitive === true,
|
|
416
|
+
fill: f?.fill || 'normal',
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function fieldDrift(sectionField, templateField) {
|
|
421
|
+
const a = normalizedFieldDef(sectionField);
|
|
422
|
+
const b = normalizedFieldDef(templateField);
|
|
423
|
+
return FIELD_CMP_KEYS.filter((k) => JSON.stringify(a[k]) !== JSON.stringify(b[k]));
|
|
424
|
+
}
|
|
425
|
+
|
|
323
426
|
/**
|
|
324
427
|
* 模板增量合并:把模板里**尚未出现在栏目**的分组与字段补进去(只增,绝不改/删)。
|
|
325
428
|
*
|
|
@@ -333,12 +436,17 @@ export function syncSectionGroups(groups, fields) {
|
|
|
333
436
|
* 模板没有的自定义分组原顺序附在后面。
|
|
334
437
|
* - 字段:栏目已有的字段原样保留(含用户改过的 label/hint/fill);缺的模板字段
|
|
335
438
|
* 插到「模板顺序里它前一个字段」之后,未在模板中的自定义字段保持原位。
|
|
439
|
+
*
|
|
440
|
+
* 另外回传 `changedFields`:**同名字段定义已分叉**的清单(只报告,绝不自动改)。
|
|
441
|
+
* 光有「只增不改」的话,模板改了默认 label/hint 是**完全无声**的 ——
|
|
442
|
+
* 用户会以为「同步过了 = 跟模板一致」,实际不一致。把差异摆出来,改不改由人定。
|
|
336
443
|
*/
|
|
337
444
|
export function mergeTemplate(section, template) {
|
|
338
445
|
const existingGroups = section?.groups || [];
|
|
339
446
|
const existingFields = section?.fields || [];
|
|
340
447
|
const groupById = new Map(existingGroups.map((g) => [g.id, g]));
|
|
341
448
|
const fieldKeys = new Set(existingFields.map((f) => f.key));
|
|
449
|
+
const fieldByKey = new Map(existingFields.map((f) => [f.key, f]));
|
|
342
450
|
|
|
343
451
|
const groups = [];
|
|
344
452
|
const seenGroup = new Set();
|
|
@@ -352,8 +460,14 @@ export function mergeTemplate(section, template) {
|
|
|
352
460
|
const tplOrder = new Map((template?.fields || []).map((f, i) => [f.key, i]));
|
|
353
461
|
const fields = [...existingFields];
|
|
354
462
|
const addedFields = [];
|
|
463
|
+
const changedFields = [];
|
|
355
464
|
for (const tf of template?.fields || []) {
|
|
356
|
-
if (fieldKeys.has(tf.key))
|
|
465
|
+
if (fieldKeys.has(tf.key)) {
|
|
466
|
+
// 同名 = 定义归栏目;只算差异用于提示
|
|
467
|
+
const diff = fieldDrift(fieldByKey.get(tf.key), tf);
|
|
468
|
+
if (diff.length) changedFields.push({ key: tf.key, diff });
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
357
471
|
// 插到第一个「模板序更靠后」的既有字段之前;找不到就追加到末尾
|
|
358
472
|
const ti = tplOrder.get(tf.key);
|
|
359
473
|
let insertAt = fields.length;
|
|
@@ -366,7 +480,7 @@ export function mergeTemplate(section, template) {
|
|
|
366
480
|
addedFields.push(tf.key);
|
|
367
481
|
}
|
|
368
482
|
|
|
369
|
-
return { groups, fields, addedGroups, addedFields };
|
|
483
|
+
return { groups, fields, addedGroups, addedFields, changedFields };
|
|
370
484
|
}
|
|
371
485
|
|
|
372
486
|
export function fieldByKey(section, key) {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// 图文字段(`type: 'md'`)里图片引用的解析与重写 —— **Node 侧唯一实现**。
|
|
2
|
+
//
|
|
3
|
+
// 语法就是普通 markdown:``,不加 `![[…]]` 这种方言。
|
|
4
|
+
// 理由:这段文字经常要原样流出去(导出成 md、贴进仓库 README、发给同事),
|
|
5
|
+
// 标准语法才不挑渲染器;方言只有本工具的编辑器认得。
|
|
6
|
+
//
|
|
7
|
+
// 前端不能 import core,`fieldEditor.jsx` 里自带一份同口径的正则
|
|
8
|
+
// (与 `toIds` 复制 `fileIdsOf` 是同一类取舍,两处必须一起改)。
|
|
9
|
+
|
|
10
|
+
// 附件 id 形如 `f_lq3k2j9a1b2c3d4`(core/ids.js 的 newId)。放宽到任意 token,
|
|
11
|
+
// 认不出的就当普通文字:任务正文里写 `` 也不该被改写。
|
|
12
|
+
const IMAGE_RE = /!\[([^\]]*)\]\(\s*([^)\s]+)\s*\)/g;
|
|
13
|
+
|
|
14
|
+
/** 外链图片:`` 引的是网上的图,不在附件库里。 */
|
|
15
|
+
export function isHttpUrl(value) {
|
|
16
|
+
return /^https?:\/\//i.test(String(value ?? '').trim());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 解析出全部图片引用(保序,含重复)。 */
|
|
20
|
+
export function parseMdImages(text) {
|
|
21
|
+
const src = String(text ?? '');
|
|
22
|
+
const out = [];
|
|
23
|
+
if (!src) return out;
|
|
24
|
+
IMAGE_RE.lastIndex = 0;
|
|
25
|
+
let m;
|
|
26
|
+
while ((m = IMAGE_RE.exec(src)) !== null) {
|
|
27
|
+
out.push({ alt: m[1], id: m[2].trim(), raw: m[0], index: m.index });
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 正文引用到的**附件** id(去重、保序)—— 用来建 `files` 表与做完整性检查。
|
|
33
|
+
* 外链图片不在附件库里,不进去:否则每张网图都要白查一次台账。 */
|
|
34
|
+
export function mdImageIds(text) {
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const { id } of parseMdImages(text)) {
|
|
38
|
+
if (isHttpUrl(id) || seen.has(id)) continue;
|
|
39
|
+
seen.add(id);
|
|
40
|
+
out.push(id);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 把正文里的图片引用改写成**人能读的一行**(CLI / 导出用):
|
|
47
|
+
* `` → `[图片: 发票截图] ~/nx-sk/files/other/发票截图.png`
|
|
48
|
+
*
|
|
49
|
+
* `lookup(id)` 返回 `{ name, path }` 或 null。null 表示附件已不在库里 ——
|
|
50
|
+
* 这时**保留**一条醒目的「图片缺失」,而不是静默删掉:删了等于让读者以为这里本来没图。
|
|
51
|
+
*/
|
|
52
|
+
export function rewriteMdImages(text, lookup) {
|
|
53
|
+
const src = String(text ?? '');
|
|
54
|
+
if (!src) return '';
|
|
55
|
+
IMAGE_RE.lastIndex = 0;
|
|
56
|
+
return src.replace(IMAGE_RE, (raw, alt, id) => {
|
|
57
|
+
const ref = String(id).trim();
|
|
58
|
+
// 外链图片不查附件库:查了必然是「缺失」,但图其实在(只是要联网)。
|
|
59
|
+
if (isHttpUrl(ref)) return `[外链图片: ${alt || ref}] ${ref}`;
|
|
60
|
+
const rec = lookup(ref);
|
|
61
|
+
if (!rec) return `[图片缺失: ${ref}]`;
|
|
62
|
+
const label = alt || rec.name || ref;
|
|
63
|
+
return rec.path ? `[图片: ${label}] ${rec.path}` : `[图片: ${label}] ${rec.name}`;
|
|
64
|
+
});
|
|
65
|
+
}
|
package/src/core/zip.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// 极简 ZIP 读写(store / deflate),零依赖。
|
|
2
|
+
//
|
|
3
|
+
// 为什么自己写:nx-sk 的运行时**不装任何 npm 包**(镜像、离线、审计都靠这一点)。
|
|
4
|
+
// 这里只需要「打一个包 / 解一个包」:条目上限 2000、单文件上限 100MB,
|
|
5
|
+
// 足够栏目导出(JSON + 截图 + PDF)。不支持 Zip64 / 加密 —— 遇到就报清晰的错,
|
|
6
|
+
// 而不是解出半个包。
|
|
7
|
+
//
|
|
8
|
+
// 写:默认 store(不压缩)。图片/PDF 本来就是压缩格式,再 deflate 省不了多少,
|
|
9
|
+
// 反而让「用系统工具打开 zip 排查」变复杂。`compress: true` 时才 deflate 文本。
|
|
10
|
+
import { deflateRawSync, inflateRawSync } from 'node:zlib';
|
|
11
|
+
|
|
12
|
+
const SIG_LOCAL = 0x04034b50;
|
|
13
|
+
const SIG_CENTRAL = 0x02014b50;
|
|
14
|
+
const SIG_EOCD = 0x06054b50;
|
|
15
|
+
const FLAG_UTF8 = 0x0800;
|
|
16
|
+
|
|
17
|
+
const MAX_ENTRIES = 2000;
|
|
18
|
+
const MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
19
|
+
const MAX_TOTAL_BYTES = 300 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
const CRC_TABLE = (() => {
|
|
22
|
+
const t = new Int32Array(256);
|
|
23
|
+
for (let i = 0; i < 256; i += 1) {
|
|
24
|
+
let c = i;
|
|
25
|
+
for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
|
26
|
+
t[i] = c;
|
|
27
|
+
}
|
|
28
|
+
return t;
|
|
29
|
+
})();
|
|
30
|
+
|
|
31
|
+
export function crc32(buf) {
|
|
32
|
+
let c = ~0;
|
|
33
|
+
for (let i = 0; i < buf.length; i += 1) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
34
|
+
return (~c) >>> 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** DOS 时间戳:ZIP 只存到 2 秒精度;用导出时刻,方便按时间排序。 */
|
|
38
|
+
function dosDateTime(d = new Date()) {
|
|
39
|
+
const time = ((d.getHours() & 31) << 11) | ((d.getMinutes() & 63) << 5) | ((d.getSeconds() >> 1) & 31);
|
|
40
|
+
const date = (((d.getFullYear() - 1980) & 127) << 9) | (((d.getMonth() + 1) & 15) << 5) | (d.getDate() & 31);
|
|
41
|
+
return { time, date };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* zip 内路径必须**相对且干净**:绝对路径、盘符、`..` 段一律拒绝。
|
|
46
|
+
* 这不是洁癖——导入时我们会按这个路径写文件,放过去就是任意文件写入。
|
|
47
|
+
*/
|
|
48
|
+
export function assertSafeZipName(name) {
|
|
49
|
+
const n = String(name ?? '');
|
|
50
|
+
if (!n || n.includes('\0')) throw new Error('ZIP 条目名为空或含非法字符');
|
|
51
|
+
if (n.startsWith('/') || n.startsWith('\\') || /^[A-Za-z]:/.test(n)) {
|
|
52
|
+
throw new Error(`ZIP 条目不能是绝对路径: ${n}`);
|
|
53
|
+
}
|
|
54
|
+
const parts = n.replace(/\\/g, '/').split('/');
|
|
55
|
+
if (parts.some((p) => p === '..')) throw new Error(`ZIP 条目不能包含 ..: ${n}`);
|
|
56
|
+
return n;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 生成 ZIP。
|
|
61
|
+
* @param {Array<{name: string, data: Buffer|string}>} entries
|
|
62
|
+
* @param {{compress?: boolean, mtime?: Date}} [opts]
|
|
63
|
+
* @returns {Buffer}
|
|
64
|
+
*/
|
|
65
|
+
export function createZip(entries, { compress = false, mtime = new Date() } = {}) {
|
|
66
|
+
const list = (entries || []).map((e) => {
|
|
67
|
+
const name = assertSafeZipName(e.name);
|
|
68
|
+
const data = Buffer.isBuffer(e.data) ? e.data : Buffer.from(String(e.data ?? ''), 'utf8');
|
|
69
|
+
const body = compress ? deflateRawSync(data) : data;
|
|
70
|
+
return { name, nameBuf: Buffer.from(name, 'utf8'), data, body, method: compress ? 8 : 0, crc: crc32(data) };
|
|
71
|
+
});
|
|
72
|
+
if (list.length > MAX_ENTRIES) throw new Error(`ZIP 条目过多(${list.length} > ${MAX_ENTRIES})`);
|
|
73
|
+
|
|
74
|
+
const { time, date } = dosDateTime(mtime);
|
|
75
|
+
const locals = [];
|
|
76
|
+
const centrals = [];
|
|
77
|
+
let offset = 0;
|
|
78
|
+
|
|
79
|
+
for (const e of list) {
|
|
80
|
+
const local = Buffer.alloc(30);
|
|
81
|
+
local.writeUInt32LE(SIG_LOCAL, 0);
|
|
82
|
+
local.writeUInt16LE(20, 4); // version needed
|
|
83
|
+
local.writeUInt16LE(FLAG_UTF8, 6);
|
|
84
|
+
local.writeUInt16LE(e.method, 8);
|
|
85
|
+
local.writeUInt16LE(time, 10);
|
|
86
|
+
local.writeUInt16LE(date, 12);
|
|
87
|
+
local.writeUInt32LE(e.crc, 14);
|
|
88
|
+
local.writeUInt32LE(e.body.length, 18);
|
|
89
|
+
local.writeUInt32LE(e.data.length, 22);
|
|
90
|
+
local.writeUInt16LE(e.nameBuf.length, 26);
|
|
91
|
+
local.writeUInt16LE(0, 28);
|
|
92
|
+
locals.push(local, e.nameBuf, e.body);
|
|
93
|
+
|
|
94
|
+
const central = Buffer.alloc(46);
|
|
95
|
+
central.writeUInt32LE(SIG_CENTRAL, 0);
|
|
96
|
+
central.writeUInt16LE(20, 4); // version made by
|
|
97
|
+
central.writeUInt16LE(20, 6); // version needed
|
|
98
|
+
central.writeUInt16LE(FLAG_UTF8, 8);
|
|
99
|
+
central.writeUInt16LE(e.method, 10);
|
|
100
|
+
central.writeUInt16LE(time, 12);
|
|
101
|
+
central.writeUInt16LE(date, 14);
|
|
102
|
+
central.writeUInt32LE(e.crc, 16);
|
|
103
|
+
central.writeUInt32LE(e.body.length, 20);
|
|
104
|
+
central.writeUInt32LE(e.data.length, 24);
|
|
105
|
+
central.writeUInt16LE(e.nameBuf.length, 28);
|
|
106
|
+
central.writeUInt16LE(0, 30); // extra
|
|
107
|
+
central.writeUInt16LE(0, 32); // comment
|
|
108
|
+
central.writeUInt16LE(0, 34); // disk
|
|
109
|
+
central.writeUInt16LE(0, 36); // internal attrs
|
|
110
|
+
central.writeUInt32LE(0, 38); // external attrs
|
|
111
|
+
central.writeUInt32LE(offset, 42);
|
|
112
|
+
centrals.push(central, e.nameBuf);
|
|
113
|
+
|
|
114
|
+
offset += local.length + e.nameBuf.length + e.body.length;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const centralBuf = Buffer.concat(centrals);
|
|
118
|
+
const eocd = Buffer.alloc(22);
|
|
119
|
+
eocd.writeUInt32LE(SIG_EOCD, 0);
|
|
120
|
+
eocd.writeUInt16LE(0, 4);
|
|
121
|
+
eocd.writeUInt16LE(0, 6);
|
|
122
|
+
eocd.writeUInt16LE(list.length, 8);
|
|
123
|
+
eocd.writeUInt16LE(list.length, 10);
|
|
124
|
+
eocd.writeUInt32LE(centralBuf.length, 12);
|
|
125
|
+
eocd.writeUInt32LE(offset, 16);
|
|
126
|
+
eocd.writeUInt16LE(0, 20);
|
|
127
|
+
|
|
128
|
+
return Buffer.concat([...locals, centralBuf, eocd]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 解 ZIP,返回 `[{ name, data }]`(目录条目不返回)。
|
|
133
|
+
* 只支持 store / deflate;加密、Zip64、超大包都明确报错。
|
|
134
|
+
*/
|
|
135
|
+
export function readZip(buf, { maxEntries = MAX_ENTRIES, maxTotalBytes = MAX_TOTAL_BYTES } = {}) {
|
|
136
|
+
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf || []);
|
|
137
|
+
if (b.length < 22) throw new Error('不是有效的 ZIP:文件太小');
|
|
138
|
+
|
|
139
|
+
// EOCD 可能被注释顶到后面;从尾部往前找,最多 64KB 注释
|
|
140
|
+
const min = Math.max(0, b.length - 22 - 0xffff);
|
|
141
|
+
let eocd = -1;
|
|
142
|
+
for (let i = b.length - 22; i >= min; i -= 1) {
|
|
143
|
+
if (b.readUInt32LE(i) === SIG_EOCD) { eocd = i; break; }
|
|
144
|
+
}
|
|
145
|
+
if (eocd < 0) throw new Error('不是有效的 ZIP:找不到目录结尾(EOCD)');
|
|
146
|
+
|
|
147
|
+
const total = b.readUInt16LE(eocd + 10);
|
|
148
|
+
const cdSize = b.readUInt32LE(eocd + 12);
|
|
149
|
+
const cdOff = b.readUInt32LE(eocd + 16);
|
|
150
|
+
if (total === 0xffff || cdOff === 0xffffffff) throw new Error('暂不支持 Zip64(条目或体积超过 4GB)');
|
|
151
|
+
if (total > maxEntries) throw new Error(`ZIP 条目过多(${total} > ${maxEntries})`);
|
|
152
|
+
if (cdOff + cdSize > b.length) throw new Error('ZIP 目录区越界:文件可能被截断');
|
|
153
|
+
|
|
154
|
+
const out = [];
|
|
155
|
+
let totalBytes = 0;
|
|
156
|
+
let p = cdOff;
|
|
157
|
+
|
|
158
|
+
for (let i = 0; i < total; i += 1) {
|
|
159
|
+
if (p + 46 > b.length || b.readUInt32LE(p) !== SIG_CENTRAL) throw new Error('ZIP 目录项损坏');
|
|
160
|
+
const flags = b.readUInt16LE(p + 8);
|
|
161
|
+
if (flags & 0x1) throw new Error('ZIP 已加密,暂不支持');
|
|
162
|
+
const method = b.readUInt16LE(p + 10);
|
|
163
|
+
const crc = b.readUInt32LE(p + 16);
|
|
164
|
+
const compSize = b.readUInt32LE(p + 20);
|
|
165
|
+
const uncompSize = b.readUInt32LE(p + 24);
|
|
166
|
+
const nameLen = b.readUInt16LE(p + 28);
|
|
167
|
+
const extraLen = b.readUInt16LE(p + 30);
|
|
168
|
+
const commentLen = b.readUInt16LE(p + 32);
|
|
169
|
+
const localOff = b.readUInt32LE(p + 42);
|
|
170
|
+
if (p + 46 + nameLen > b.length) throw new Error('ZIP 目录项越界');
|
|
171
|
+
|
|
172
|
+
const name = b.slice(p + 46, p + 46 + nameLen).toString('utf8');
|
|
173
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
174
|
+
if (name.endsWith('/')) continue; // 目录条目
|
|
175
|
+
|
|
176
|
+
const safe = assertSafeZipName(name);
|
|
177
|
+
if (uncompSize > MAX_FILE_BYTES) throw new Error(`ZIP 内单文件过大: ${safe}(${uncompSize} 字节)`);
|
|
178
|
+
totalBytes += uncompSize;
|
|
179
|
+
if (totalBytes > maxTotalBytes) throw new Error('ZIP 解压后总体积过大');
|
|
180
|
+
|
|
181
|
+
if (localOff + 30 > b.length || b.readUInt32LE(localOff) !== SIG_LOCAL) throw new Error(`ZIP 条目头损坏: ${safe}`);
|
|
182
|
+
const lNameLen = b.readUInt16LE(localOff + 26);
|
|
183
|
+
const lExtraLen = b.readUInt16LE(localOff + 28);
|
|
184
|
+
const dataStart = localOff + 30 + lNameLen + lExtraLen;
|
|
185
|
+
if (dataStart + compSize > b.length) throw new Error(`ZIP 条目被截断: ${safe}`);
|
|
186
|
+
|
|
187
|
+
const raw = b.slice(dataStart, dataStart + compSize);
|
|
188
|
+
let data;
|
|
189
|
+
if (method === 0) data = raw;
|
|
190
|
+
else if (method === 8) data = inflateRawSync(raw);
|
|
191
|
+
else throw new Error(`不支持的压缩方式 ${method}: ${safe}`);
|
|
192
|
+
if (data.length !== uncompSize) throw new Error(`ZIP 条目长度不符: ${safe}`);
|
|
193
|
+
if (crc32(data) !== crc) throw new Error(`ZIP 条目校验失败(CRC 不符): ${safe}`);
|
|
194
|
+
|
|
195
|
+
out.push({ name: safe, data });
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
@@ -9,11 +9,12 @@ import { loadStore, mutateStore, snapshotStore } from '../../core/store.js';
|
|
|
9
9
|
import { assertFieldKey } from '../../core/paths.js';
|
|
10
10
|
import { badInput, conflict, notFound } from '../../core/errors.js';
|
|
11
11
|
import { BOOL_FALSE, BOOL_TRUE, findKvSection, findSection, isSensitiveField, sectionNames } from '../../core/fields.js';
|
|
12
|
-
import { completeness, displaySensitive, serializeEntry } from '../../core/render.js';
|
|
12
|
+
import { completeness, displaySensitive, isBlank, serializeEntry } from '../../core/render.js';
|
|
13
13
|
import { newId, nowIso } from '../../core/ids.js';
|
|
14
14
|
import { decryptValue, encryptValue, isCipherBlob, maskValue } from '../../core/crypto.js';
|
|
15
15
|
import { resolveVaultKey, sensitiveViewer } from '../../core/vault.js';
|
|
16
16
|
import * as att from '../../core/attachments.js';
|
|
17
|
+
import { mdImageIds, rewriteMdImages } from '../../core/mdimages.js';
|
|
17
18
|
import { displayPath } from '../../core/paths.js';
|
|
18
19
|
|
|
19
20
|
export function mustFindSection(store, ref) {
|
|
@@ -225,21 +226,30 @@ export async function getEntry(ref, { mask, section } = {}) {
|
|
|
225
226
|
if (!sec) throw notFound(`条目 ${entry.id} 指向的栏目已不存在: ${entry.section}`);
|
|
226
227
|
const decrypt = await sensitiveViewer(sec, [entry]);
|
|
227
228
|
const serialized = serializeEntry(sec, entry, { mask: !!mask, decrypt });
|
|
228
|
-
//
|
|
229
|
-
//
|
|
229
|
+
// 附带附件信息:键 = 附件 id / 文件名,值为名称 / 路径 / 链接。
|
|
230
|
+
// 两个来源:`file` 字段挂的附件,以及**图文字段正文里内嵌的图片**(``)——
|
|
231
|
+
// 后者不进来的话,`entry get` 只能打印一串 f_xxx,而人/agent 要的是「这份文件在磁盘哪」。
|
|
230
232
|
const files = {};
|
|
233
|
+
const putFile = (ref) => {
|
|
234
|
+
if (!ref || files[ref]) return;
|
|
235
|
+
const rec = att.findFileRecord(store, ref);
|
|
236
|
+
if (!rec) return;
|
|
237
|
+
const links = att.fileLinks(rec);
|
|
238
|
+
files[ref] = {
|
|
239
|
+
id: rec.id, name: rec.name, ext: rec.ext, isImage: att.isImageExt(rec.ext),
|
|
240
|
+
path: displayPath(att.fileRecordAbs(rec)), url: links.url, downloadUrl: links.downloadUrl,
|
|
241
|
+
};
|
|
242
|
+
};
|
|
231
243
|
for (const f of sec.fields.filter((x) => x.type === 'file')) {
|
|
232
|
-
for (const ref of att.fileIdsOf(serialized.values[f.key]))
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
files[ref] = {
|
|
237
|
-
id: rec.id, name: rec.name, ext: rec.ext, isImage: att.isImageExt(rec.ext),
|
|
238
|
-
path: displayPath(att.fileRecordAbs(rec)), url: links.url, downloadUrl: links.downloadUrl,
|
|
239
|
-
};
|
|
240
|
-
}
|
|
244
|
+
for (const ref of att.fileIdsOf(serialized.values[f.key])) putFile(ref);
|
|
245
|
+
}
|
|
246
|
+
for (const f of sec.fields.filter((x) => x.type === 'md')) {
|
|
247
|
+
for (const id of mdImageIds(serialized.values[f.key])) putFile(id);
|
|
241
248
|
}
|
|
242
|
-
|
|
249
|
+
// 字段类型随读出来:CLI 渲染要知道哪个值是「图文」,才能把 f_xxx 换成文件名/路径
|
|
250
|
+
const fieldTypes = {};
|
|
251
|
+
for (const f of sec.fields) fieldTypes[f.key] = f.type;
|
|
252
|
+
return { ...serialized, sectionTitle: sec.title, mask: !!mask, files, fieldTypes };
|
|
243
253
|
}
|
|
244
254
|
|
|
245
255
|
export async function addEntry({ section, title, set, data, tags, 'dry-run': dryRun } = {}) {
|
|
@@ -532,8 +542,11 @@ export function renderEntryList(d) {
|
|
|
532
542
|
lines.push(` ${e.id} ${(e.title || '(无名)').padEnd(16)} ${String(e.completeness.ratio).padStart(3)}% 完整度 [${e.section}]`);
|
|
533
543
|
const missing = e.missing.slice(0, 6).map((m) => m.label).join('、');
|
|
534
544
|
if (missing) lines.push(` 未填: ${missing}${e.missing.length > 6 ? ` 等 ${e.missing.length} 项` : ''}`);
|
|
535
|
-
|
|
536
|
-
|
|
545
|
+
// 「不填」只报**真的空着**的可选字段。可选字段填了值还列出来,会让人以为
|
|
546
|
+
// 这份数据缺东西(`excluded` 是策略标记,不是空值清单)。
|
|
547
|
+
const skipped = (e.excluded || []).filter((m) => isBlank(e.values?.[m.key]));
|
|
548
|
+
if (skipped.length) {
|
|
549
|
+
lines.push(` 不填: ${skipped.map((m) => m.label).join('、')}(主动跳过,不计入完整度)`);
|
|
537
550
|
}
|
|
538
551
|
}
|
|
539
552
|
lines.push('', '看全部字段: nx-sk entry get <id> --json 或 nx-sk entry fields --section <id>');
|
|
@@ -545,26 +558,36 @@ export function renderEntryList(d) {
|
|
|
545
558
|
* - 附件(`file` 字段,值可能是 id 列表):解析成「附件名 物理路径」,**几个占几行**
|
|
546
559
|
* - 其它数组(tags 之类):仍旧一行、顿号连接——不要顺手把它们也拆成多行
|
|
547
560
|
*/
|
|
548
|
-
function valueLines(d, v) {
|
|
561
|
+
function valueLines(d, v, type) {
|
|
549
562
|
const asFile = (x) => (d.files && d.files[x] ? `${d.files[x].name} ${d.files[x].path}` : null);
|
|
550
563
|
if (Array.isArray(v) && v.length && v.every((x) => asFile(x))) return v.map(asFile);
|
|
551
564
|
const one = asFile(v);
|
|
552
565
|
if (one) return [one];
|
|
553
566
|
if (Array.isArray(v)) return [v.join('、')];
|
|
554
567
|
if (typeof v === 'boolean') return [v ? '是' : '否'];
|
|
555
|
-
|
|
568
|
+
// 图文字段:`` → `[图片: 说明] 本地路径`。不换的话 CLI 只会吐一串 id,
|
|
569
|
+
// 而「这个 id 对应哪个文件」正是读的人唯一想知道的。缺附件时保留醒目的「图片缺失」。
|
|
570
|
+
const s = String(v);
|
|
571
|
+
const text = type === 'md' ? rewriteMdImages(s, (id) => d.files?.[id] || null) : s;
|
|
572
|
+
// 多行正文(YAML / Dockerfile / 一段说明)按行拆开再回填缩进。
|
|
573
|
+
// 不拆的话只有第一行带着键名列宽,后面每一行都从第 0 列开始 ——
|
|
574
|
+
// 一段 40 行的清单打印出来像"掉出了表格",人眼完全没法对。
|
|
575
|
+
return text.includes('\n') ? text.split('\n') : [text];
|
|
556
576
|
}
|
|
557
577
|
|
|
558
578
|
export function renderEntryGet(d) {
|
|
559
579
|
const c = d.completeness || {};
|
|
580
|
+
// 只算**真的空着**的可选字段:填了值的可选字段不属于「跳过」,
|
|
581
|
+
// 否则一份填满的资料会被报成「另有 7 项已标不填」,自相矛盾。
|
|
582
|
+
const skipped = (d.excluded || []).filter((m) => isBlank(d.values?.[m.key]));
|
|
560
583
|
const lines = [
|
|
561
584
|
`${d.title || d.id}(${d.id})· 栏目 ${d.section} · 完整度 ${c.filled}/${c.total} = ${c.ratio}%`
|
|
562
|
-
+ (
|
|
585
|
+
+ (skipped.length ? ` · 另有 ${skipped.length} 项已标不填` : '')
|
|
563
586
|
+ `${d.mask ? ' · 已打码' : ''}`,
|
|
564
587
|
];
|
|
565
588
|
for (const [k, v] of Object.entries(d.values)) {
|
|
566
589
|
if (v === null) continue;
|
|
567
|
-
const rendered = valueLines(d, v);
|
|
590
|
+
const rendered = valueLines(d, v, d.fieldTypes?.[k]);
|
|
568
591
|
lines.push(` ${k.padEnd(24)} ${rendered[0]}`);
|
|
569
592
|
for (const extra of rendered.slice(1)) lines.push(` ${' '.repeat(24)} ${extra}`);
|
|
570
593
|
}
|
|
@@ -573,9 +596,9 @@ export function renderEntryGet(d) {
|
|
|
573
596
|
}
|
|
574
597
|
// 「不填」和「未填」必须分开说:混在一起会让人以为这些字段还欠着,
|
|
575
598
|
// 而它们恰恰是用户主动决定跳过的。
|
|
576
|
-
if (
|
|
577
|
-
lines.push('', `不填(${
|
|
578
|
-
+
|
|
599
|
+
if (skipped.length) {
|
|
600
|
+
lines.push('', `不填(${skipped.length})· 主动跳过、不计入完整度: `
|
|
601
|
+
+ skipped.map((m) => `${m.label}(${m.key}|${m.fillLabel || m.fill})`).join('、'));
|
|
579
602
|
}
|
|
580
603
|
return lines.join('\n');
|
|
581
604
|
}
|