nx-sk 0.27.0 → 0.28.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 +24 -0
- package/README.md +18 -5
- package/bin/nx-sk.mjs +21 -3
- package/{src → dist}/core/argsafe.js +24 -24
- package/dist/core/attachments.js +463 -0
- package/dist/core/crypto.js +71 -0
- package/{src → dist}/core/errors.js +27 -29
- package/dist/core/fields/behaviors/bool.js +24 -0
- package/dist/core/fields/behaviors/common.js +37 -0
- package/dist/core/fields/behaviors/date.js +12 -0
- package/dist/core/fields/behaviors/entrylist.js +112 -0
- package/dist/core/fields/behaviors/file.js +38 -0
- package/dist/core/fields/behaviors/md.js +21 -0
- package/dist/core/fields/behaviors/month.js +12 -0
- package/dist/core/fields/behaviors/number.js +18 -0
- package/dist/core/fields/behaviors/secret.js +12 -0
- package/dist/core/fields/behaviors/select.js +12 -0
- package/dist/core/fields/behaviors/source.js +71 -0
- package/dist/core/fields/behaviors/table.js +185 -0
- package/dist/core/fields/behaviors/tags.js +20 -0
- package/dist/core/fields/behaviors/text.js +12 -0
- package/dist/core/fields/behaviors/textarea.js +12 -0
- package/dist/core/fields/registry.js +85 -0
- package/dist/core/fields/seal/common.js +39 -0
- package/dist/core/fields/seal/entrylist.js +46 -0
- package/dist/core/fields/seal/index.js +23 -0
- package/dist/core/fields/seal/table.js +86 -0
- package/dist/core/fields.js +828 -0
- package/dist/core/fsx.js +119 -0
- package/dist/core/ids.js +16 -0
- package/{src → dist}/core/mdimages.js +35 -35
- package/dist/core/open.js +36 -0
- package/{src → dist}/core/paths.js +57 -71
- package/dist/core/render.js +167 -0
- package/dist/core/securezip.js +68 -0
- package/dist/core/sensitive.js +27 -0
- package/{src → dist}/core/serve-port.js +59 -61
- package/dist/core/source.js +261 -0
- package/dist/core/store.js +296 -0
- package/dist/core/values.js +42 -0
- package/dist/core/vault.js +131 -0
- package/dist/core/zip.js +198 -0
- package/dist/index.js +12 -0
- package/dist/modules/entries/index.js +280 -0
- package/dist/modules/entries/service.js +1239 -0
- package/dist/modules/files/index.js +123 -0
- package/dist/modules/files/service.js +328 -0
- package/dist/modules/sections/index.js +142 -0
- package/dist/modules/sections/service.js +806 -0
- package/dist/modules/settings/index.js +32 -0
- package/dist/modules/settings/service.js +234 -0
- package/dist/modules/skill/index.js +50 -0
- package/dist/modules/skill/service.js +238 -0
- package/dist/modules/system/index.js +40 -0
- package/dist/modules/system/service.js +157 -0
- package/dist/modules/transfer/index.js +112 -0
- package/dist/modules/transfer/service.js +1004 -0
- package/dist/runtime/api.js +139 -0
- package/dist/runtime/cli.js +388 -0
- package/dist/runtime/module-registry.js +88 -0
- package/dist/runtime/registry.js +62 -0
- package/dist/runtime/server.js +78 -0
- package/dist/runtime/spec.js +193 -0
- package/dist/shared/blockLayout.js +382 -0
- package/dist/shared/fields/types.js +36 -0
- package/dist/shared/model.js +1 -0
- package/dist/shared/table.js +957 -0
- package/{src/web/public/assets/files--kTIDQLp.js → dist/web/public/assets/files-DDDN-GbJ.js} +1 -1
- package/dist/web/public/assets/index-BpUTnBjj.js +41 -0
- package/dist/web/public/assets/view-BgznDLss.js +46 -0
- package/{src/web/public/assets/view-ChbT0W1t.js → dist/web/public/assets/view-C6I3Vf9F.js} +2 -2
- package/{src/web/public/assets/view-Bji535Kc.js → dist/web/public/assets/view-ClLIweOz.js} +1 -1
- package/dist/web/public/assets/view-DOpzNX1y.js +3 -0
- package/dist/web/public/assets/view-TJPSoVht.js +1 -0
- package/{src → dist}/web/public/index.html +1 -1
- package/package.json +15 -8
- package/src/core/attachments.js +0 -446
- package/src/core/crypto.js +0 -73
- package/src/core/fields.js +0 -895
- package/src/core/fsx.js +0 -103
- package/src/core/ids.js +0 -19
- package/src/core/open.js +0 -32
- package/src/core/render.js +0 -239
- package/src/core/securezip.js +0 -72
- package/src/core/source.js +0 -230
- package/src/core/store.js +0 -297
- package/src/core/vault.js +0 -125
- package/src/core/zip.js +0 -198
- package/src/index.js +0 -13
- package/src/modules/entries/index.js +0 -286
- package/src/modules/entries/kvTable.jsx +0 -165
- package/src/modules/entries/service.js +0 -1508
- package/src/modules/entries/view.jsx +0 -666
- package/src/modules/export/index.js +0 -14
- package/src/modules/export/view.jsx +0 -25
- package/src/modules/files/index.js +0 -125
- package/src/modules/files/service.js +0 -327
- package/src/modules/files/view.jsx +0 -400
- package/src/modules/sections/index.js +0 -144
- package/src/modules/sections/service.js +0 -815
- package/src/modules/sections/view.jsx +0 -1199
- package/src/modules/settings/index.js +0 -34
- package/src/modules/settings/navManager.jsx +0 -213
- package/src/modules/settings/service.js +0 -221
- package/src/modules/settings/view.jsx +0 -224
- package/src/modules/skill/index.js +0 -52
- package/src/modules/skill/service.js +0 -237
- package/src/modules/system/index.js +0 -42
- package/src/modules/system/service.js +0 -163
- package/src/modules/transfer/index.js +0 -113
- package/src/modules/transfer/service.js +0 -958
- package/src/runtime/api.js +0 -140
- package/src/runtime/cli.js +0 -352
- package/src/runtime/registry.js +0 -67
- package/src/runtime/server.js +0 -80
- package/src/runtime/spec.js +0 -169
- package/src/shared/blockLayout.js +0 -367
- package/src/shared/table.js +0 -884
- package/src/web/frontend/App.jsx +0 -274
- package/src/web/frontend/activeView.js +0 -42
- package/src/web/frontend/api/client.js +0 -26
- package/src/web/frontend/api/files.js +0 -49
- package/src/web/frontend/api/zip.js +0 -34
- package/src/web/frontend/components/blockBoard.jsx +0 -307
- package/src/web/frontend/components/fieldEditor.jsx +0 -737
- package/src/web/frontend/components/lightbox.jsx +0 -96
- package/src/web/frontend/components/listDrag.jsx +0 -82
- package/src/web/frontend/components/mdView.jsx +0 -193
- package/src/web/frontend/components/navModel.js +0 -293
- package/src/web/frontend/components/sourcePanel.jsx +0 -558
- package/src/web/frontend/components/tableField.jsx +0 -635
- package/src/web/frontend/components/transferPanel.jsx +0 -494
- package/src/web/frontend/components/ui.jsx +0 -152
- package/src/web/frontend/index.html +0 -16
- package/src/web/frontend/listSort.js +0 -18
- package/src/web/frontend/main.jsx +0 -13
- package/src/web/frontend/mdInline.js +0 -185
- package/src/web/frontend/mdSmart.js +0 -164
- package/src/web/frontend/mdSource.js +0 -41
- package/src/web/frontend/registry.js +0 -21
- package/src/web/frontend/store.jsx +0 -107
- package/src/web/frontend/styles.css +0 -1535
- package/src/web/public/assets/index-Dk-VfQfE.js +0 -41
- package/src/web/public/assets/view-BaXP_gJq.js +0 -1
- package/src/web/public/assets/view-Bcy062Uw.js +0 -3
- package/src/web/public/assets/view-D_mRuEro.js +0 -46
- package/src/web/public/favicon-16.png +0 -0
- package/src/web/public/favicon-32.png +0 -0
- package/src/web/public/favicon-48.png +0 -0
- package/src/web/public/favicon.ico +0 -0
- package/src/web/public/logo-rounded.png +0 -0
- package/src/web/public/logo.png +0 -0
- /package/{src → dist}/web/public/assets/index-DDEmDb0K.css +0 -0
- /package/{src → dist}/web/public/assets/view-CgLZr6WS.css +0 -0
- /package/{src → dist}/web/public/assets/zip-gQ69BVQr.js +0 -0
- /package/{src/web/frontend → dist/web}/public/favicon-16.png +0 -0
- /package/{src/web/frontend → dist/web}/public/favicon-32.png +0 -0
- /package/{src/web/frontend → dist/web}/public/favicon-48.png +0 -0
- /package/{src/web/frontend → dist/web}/public/favicon.ico +0 -0
- /package/{src/web/frontend → dist/web}/public/logo-rounded.png +0 -0
- /package/{src/web/frontend → dist/web}/public/logo.png +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
本文件格式参考 [Keep a Changelog](https://keepachangelog.com/),中文正文。
|
|
4
4
|
|
|
5
|
+
## 0.28.0 (2026-11-10)
|
|
6
|
+
|
|
7
|
+
**全量 TypeScript 化:后端与前端源码统一为 `.ts/.tsx`;字段与模块改为注册表驱动。对外行为不变。**
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **源码全量 TypeScript**:`src/**` 不再有 `.js/.jsx`;`pnpm build` 先用 `tsc -p tsconfig.build.json`
|
|
12
|
+
产出 `dist/**/*.js`(`.ts` 导入后缀在 emit 时改写),再把面板构建到 `dist/web/public`。
|
|
13
|
+
- **字段收敛为穷举注册表**:服务端 `FIELD_BEHAVIOR` / `FIELD_SEAL`、前端 `FIELD_CONTROLS`
|
|
14
|
+
都是 `Record<FieldType, …>`;`FIELD_TYPES` 加一项而漏实现/漏控件,会直接编译失败。
|
|
15
|
+
- **模块「目录即声明」**:`src/modules/<id>/index.ts` 自动装载,新增模块不再需要改注册表与 eslint 清单。
|
|
16
|
+
- **前端只消费 `shared`**:类型与零依赖纯函数从 `src/shared/**` 引入,eslint 边界规则覆盖 `.ts/.tsx`,
|
|
17
|
+
继续拦住 `import '../../core/**'` 与 `import '../../modules/**'`。
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **导出 ZIP 时表格的敏感列按列定义解密**:此前 `plainValues` 只处理 `entrylist`,含敏感列的表格会带密文出包,
|
|
22
|
+
换机器后永久不可解(独立提交 `fc4b9e2`)。
|
|
23
|
+
|
|
24
|
+
### 说明
|
|
25
|
+
|
|
26
|
+
- 命令、HTTP 响应形状、面板文案与交互、`store.json` 格式均未变化;`engines.node` 仍是 `>=18.17`。
|
|
27
|
+
- 贡献者:单测直接执行 `.ts` 需要 Node `>=22.18`;改了后端源码要重新 `pnpm build`。
|
|
28
|
+
|
|
5
29
|
## 0.27.0 (2026-11-08)
|
|
6
30
|
|
|
7
31
|
**表格字段重做:列头 ⋮ 属性菜单、行列都有抓手、去掉了那一整块「列设置」表单。**
|
package/README.md
CHANGED
|
@@ -11,13 +11,26 @@ nx-sk serve # 打开面板(http://127.0.0.1:7866)
|
|
|
11
11
|
|
|
12
12
|
## 安装
|
|
13
13
|
|
|
14
|
+
**要求 Node.js >= 18.17。** 包内是构建好的 `dist/`(纯 JS),运行时不需要 TypeScript。
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm i -g nx-sk # 命令名就是包名
|
|
18
|
+
nx-sk version
|
|
19
|
+
nx-sk serve # 打开面板
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 开发
|
|
23
|
+
|
|
24
|
+
**开发/测试需要 Node >= 22.18**(单测用 `node --test` 直接跑 `.ts`,依赖 Node 原生类型剥离);
|
|
25
|
+
运行 CLI 与面板仍只需 >= 18.17。改了后端要重新构建——`dist/` 是运行时唯一入口。
|
|
26
|
+
|
|
14
27
|
命令名就是包名。**推荐做成全局垫片**(全局 node_modules 里是指向本项目的软链,
|
|
15
28
|
改代码立刻生效,不用重新安装):
|
|
16
29
|
|
|
17
30
|
```bash
|
|
18
31
|
cd <本项目>
|
|
19
32
|
pnpm install # 只为构建面板;CLI 侧零依赖
|
|
20
|
-
pnpm run build #
|
|
33
|
+
pnpm run build # tsc → dist/**/*.js,vite → dist/web/public
|
|
21
34
|
npm link # → 全局多了 nx-sk / nx-sk.cmd / nx-sk.ps1
|
|
22
35
|
nx-sk version # 从任何目录都能跑
|
|
23
36
|
nx-sk serve # 打开面板
|
|
@@ -45,7 +58,7 @@ nx-sk serve # 打开面板
|
|
|
45
58
|
>
|
|
46
59
|
> 别忘了 `chmod +x ~/.local/bin/nx-sk`。验证:`cd / && nx-sk version`。
|
|
47
60
|
|
|
48
|
-
>
|
|
61
|
+
> 不想污染全局也行:先 `pnpm build`,再 `node bin/nx-sk.mjs <子命令>`,或 `npx --no-install . <子命令>`。
|
|
49
62
|
|
|
50
63
|
### skill:用**软链垫片**(推荐)
|
|
51
64
|
|
|
@@ -516,9 +529,9 @@ pnpm test # = lint + build + 冒烟 + 单测
|
|
|
516
529
|
| --- | --- |
|
|
517
530
|
| `bin/nx-sk.mjs` | 唯一入口,只转发 argv + 兜异常 |
|
|
518
531
|
| `src/core/` | 零业务语义:paths / errors / store / fsx / crypto / vault / fields / attachments / render / ids / open |
|
|
519
|
-
| `src/modules/<域>/` | `index.js`(action 声明) + `service.js`(业务)
|
|
532
|
+
| `src/modules/<域>/` | `index.js`(action 声明) + `service.js`(业务)。**不声明视图** |
|
|
520
533
|
| `src/runtime/` | registry / spec / cli / api / server —— 装配层,不写业务 |
|
|
521
|
-
| `src/web/frontend/` | React 壳 + 组件 +
|
|
534
|
+
| `src/web/frontend/` | React 壳 + 组件 + `modules/<域>/view.jsx` + 视图注册表(视图只住这里) |
|
|
522
535
|
| `assets/nx-sk/` | 随包分发的 skill(SKILL.md + references/) |
|
|
523
536
|
| `tests/unit/` · `tests/smoke.mjs` | 一致性断言 + 端到端冒烟 |
|
|
524
537
|
|
|
@@ -553,7 +566,7 @@ agent 手册** —— 它写在创建本项目的脚手架 skill `server-cli-web
|
|
|
553
566
|
| 把打码值当成新值写回去 | 密钥字段被写成 `sk-1****cdef` 这串掩码,**原凭据永久丢失** | 写入前比对掩码:相等就当成「未改动」保留原密文;含 `******` 的值直接拒绝 |
|
|
554
567
|
| 删条目/栏目没有留退路 | 误删即不可逆;这是用户的**唯一一份**个人信息 | 破坏性写操作一律先 `transfer export` 导出,导出的包就是唯一回退手段 —— 不另设本地备份模块 |
|
|
555
568
|
| store.json 解析失败就当空库继续 | 下一次写入把用户数据永久覆盖 | 先把损坏文件改名为 `store.json.corrupt-<时间>.json`,再降级返回空结构 |
|
|
556
|
-
| 前端 import 了 `node:*` 或 `core/` | Vite 把 Node 侧代码打进浏览器包,dev 模式白屏 | lint 的 `files`
|
|
569
|
+
| 前端 import 了 `node:*` 或 `core/` | Vite 把 Node 侧代码打进浏览器包,dev 模式白屏 | lint 的 `files` 覆盖整个 `src/web/frontend/**`(视图都在这里),禁掉 `node:*` / `core` / `runtime` / `service.js` |
|
|
557
570
|
| 新增模块忘了往 eslint 互依禁列里补一行 | **静默**:新模块变成「谁都可以依赖」,且规则随模块数增加持续衰减 | 在禁列旁留注释说明它是**逐模块枚举**的、漏补是静默的;加模块前先读 `eslint.config.js` |
|
|
558
571
|
| 代理前缀 `/api` 与前端源码目录 `api/` 撞车 | 只在 dev 模式整页白屏,报错却是 404 / ECONNREFUSED,离根因很远 | vite 代理加 `bypass`,按「是不是前端资源」分流;并用测试钉住 `shouldServeLocally` |
|
|
559
572
|
| 把「上传 / 选择」放到附件中心,再让条目去引用 | 条目里看不到上传入口,用户不知道该在哪儿加文件;附件中心还被迫预定义分类与槽位,跟栏目脱节 | **上传 / 选择发生在栏目条目里**(`file` 字段的值 = 附件 id);附件中心由栏目驱动、只做统一展示与库存管理,**不预定义分类、不做指派** |
|
package/bin/nx-sk.mjs
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// nx-sk
|
|
3
|
-
// 所有命令分发都在
|
|
4
|
-
|
|
2
|
+
// nx-sk 的唯一可执行入口:转发 argv、兜住未捕获的异常。
|
|
3
|
+
// 所有命令分发都在 dist/runtime/cli.js(那里才是命令表)——只跑构建产物,
|
|
4
|
+
// 不在运行时做 TypeScript 类型剥离(Node 对 node_modules 下的 .ts 直接拒绝)。
|
|
5
|
+
//
|
|
6
|
+
// 首次从源码仓库直接跑、或改了后端还没构建时,dist 不存在:给一句能照做的提示,
|
|
7
|
+
// 而不是把 ERR_MODULE_NOT_FOUND 的栈甩给用户。
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const cliUrl = new URL('../dist/runtime/cli.js', import.meta.url);
|
|
12
|
+
|
|
13
|
+
if (!existsSync(fileURLToPath(cliUrl))) {
|
|
14
|
+
process.stderr.write(
|
|
15
|
+
'nx-sk: 找不到构建产物 dist/runtime/cli.js。\n'
|
|
16
|
+
+ '源码仓库需要先构建:pnpm build(npm run build)——它会先 tsc 产出 dist,再构建面板。\n'
|
|
17
|
+
+ '如果你是用 npm 全局安装的,这说明包不完整,请重装:npm i -g nx-sk。\n',
|
|
18
|
+
);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { runCli } = await import(cliUrl.href);
|
|
5
23
|
|
|
6
24
|
runCli(process.argv.slice(2)).catch((err) => {
|
|
7
25
|
const message = err && err.message ? err.message : String(err);
|
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
// 又会拦住用 --data 写入的合法 CRLF 多行值(高假阳性);LF 截断后则与合法单行值
|
|
13
13
|
// 完全不可区分。可靠的信号不存在,因此只能提供可靠通道 + 修好通道本身。
|
|
14
14
|
import { readFileSync } from 'node:fs';
|
|
15
|
-
import { badInput } from
|
|
16
|
-
|
|
15
|
+
import { badInput } from "./errors.js";
|
|
17
16
|
/**
|
|
18
17
|
* `--data` 的值:JSON 字面量 / `@文件路径` / `-`(stdin)。
|
|
19
18
|
*
|
|
@@ -21,33 +20,34 @@ import { badInput } from './errors.js';
|
|
|
21
20
|
* 「读 stdin」因此不是一个无法测试的隐式依赖,也让本模块保持纯函数(不自己碰 fd 0)。
|
|
22
21
|
*/
|
|
23
22
|
export function parseDataArg(raw, takeStdin) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
const s = String(raw ?? '').trim();
|
|
24
|
+
if (!s)
|
|
25
|
+
throw badInput('--data 需要一个 JSON 字面量、@文件路径,或 -(从 stdin 读)');
|
|
26
|
+
if (s === '-' || s === '@-')
|
|
27
|
+
return parseJson(takeStdin(), '-');
|
|
28
|
+
if (s.startsWith('@'))
|
|
29
|
+
return parseJson(readText(s.slice(1)), s.slice(1));
|
|
30
|
+
return parseJson(s, '--data'); // 直接给的字面 JSON
|
|
29
31
|
}
|
|
30
|
-
|
|
31
32
|
/** 值本身走 stdin 的入口(`key set NAME -`)。true 表示「这个值来自 stdin」。 */
|
|
32
33
|
export function parseStdinValue(raw) {
|
|
33
|
-
|
|
34
|
+
return String(raw ?? '').trim() === '-';
|
|
34
35
|
}
|
|
35
|
-
|
|
36
36
|
function parseJson(text, src) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(text);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
throw badInput(`--data 不是合法 JSON(${src}): ${e.message}`);
|
|
42
|
+
}
|
|
42
43
|
}
|
|
43
|
-
|
|
44
44
|
function readText(p) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
try {
|
|
46
|
+
return readFileSync(p, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
// 路径写法是最常见的坑:Git Bash 的 /tmp 在 node 侧看不到(真实事故 ENOENT),
|
|
50
|
+
// 所以错误里直接把 Windows 路径形式写出来,省掉一轮排查。
|
|
51
|
+
throw badInput(`读取文件失败: ${p}(${e.message})—— Windows 上请用 C:/Users/... 形式的路径`);
|
|
52
|
+
}
|
|
52
53
|
}
|
|
53
|
-
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
// 附件(二进制原件)的磁盘管理:~/nx-sk/files/ 下存储,元数据随 store.json 落盘。
|
|
2
|
+
//
|
|
3
|
+
// 设计要点:
|
|
4
|
+
// - 本机路径导入**默认移动**(用户明确要求:提交附件 = 把文件搬进 nx-sk 目录),
|
|
5
|
+
// `--copy` 才保留原件;Web 上传只有「复制」语义(浏览器删不掉任意路径的文件)。
|
|
6
|
+
// - 「可逆」:`file restore` 能把附件移回原始位置(或指定目录),并撤销台账记录;
|
|
7
|
+
// `--copy` 则只导出一份副本、库中保留。
|
|
8
|
+
// - 同 sha256 去重:完全相同的文件不重复占空间,直接回已有记录。
|
|
9
|
+
// - 元数据是**台账**:物理文件永远以台账 rel 为准;所有移动先改物理文件、再在事务里改台账。
|
|
10
|
+
// - 链接方式:条目里 `file` 字段的值 = 附件 id(或唯一文件名)。id 全局唯一,
|
|
11
|
+
// 天然实现「各条目各自的附件空间互不串台」;同一份原件也可被多条条目复用(去重后同 id)。
|
|
12
|
+
// 附件**不预定义任何分类/槽位结构**:附件中心按「哪些栏目在用它」动态分组(见 fileReferences)。
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import fsp from 'node:fs/promises';
|
|
15
|
+
import { createReadStream } from 'node:fs';
|
|
16
|
+
import { basename, dirname, extname, join, parse, resolve, sep } from 'node:path';
|
|
17
|
+
import { badInput } from "./errors.js";
|
|
18
|
+
import { displayPath, filesDir, isInside, toPosix } from "./paths.js";
|
|
19
|
+
import { ensureDir, hashFile } from "./fsx.js";
|
|
20
|
+
import { newId, nowIso } from "./ids.js";
|
|
21
|
+
import { behaviorOf } from "./fields/registry.js";
|
|
22
|
+
/**
|
|
23
|
+
* 「这个字段有没有可能引用附件」——问行为体本身,而不是在这里复述一份类型清单。
|
|
24
|
+
*
|
|
25
|
+
* 探针同时覆盖三种引用载体(附件 id 字符串 / 资料袋 file item / 资料袋 text 正文里的
|
|
26
|
+
* ``)。`refs` 对探针仍返回空数组的类型(text / tags / table / entrylist…)
|
|
27
|
+
* 就是不可能引用的类型。这样以后加一种「能挂附件」的类型,只要它自己的 refs 认这些
|
|
28
|
+
* 载体,这里自动算数,不用回来改 attachments。
|
|
29
|
+
*/
|
|
30
|
+
const REF_PROBES = [
|
|
31
|
+
'f_probe', // file:单值 id(也涵盖 "f_x,f_y" 形态)
|
|
32
|
+
'', // md:正文里的图片引用
|
|
33
|
+
[{ kind: 'file', fileId: 'f_probe' }, { kind: 'text', text: '' }], // source:资料袋
|
|
34
|
+
];
|
|
35
|
+
const mayReferenceFiles = (f) => REF_PROBES.some((probe) => behaviorOf(f.type).refs(f, probe).length > 0);
|
|
36
|
+
// —— 分类 ——
|
|
37
|
+
export const FILE_CATEGORIES = [
|
|
38
|
+
{ id: 'resume', label: '简历' },
|
|
39
|
+
{ id: 'avatar', label: '头像' },
|
|
40
|
+
{ id: 'transcript', label: '成绩单' },
|
|
41
|
+
{ id: 'certificate', label: '证书' },
|
|
42
|
+
{ id: 'document', label: '文档' },
|
|
43
|
+
{ id: 'other', label: '其他' },
|
|
44
|
+
];
|
|
45
|
+
export const CATEGORY_IDS = FILE_CATEGORIES.map((c) => c.id);
|
|
46
|
+
export const categoryLabel = (id) => FILE_CATEGORIES.find((c) => c.id === id)?.label || '其他';
|
|
47
|
+
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg', '.avif', '.ico']);
|
|
48
|
+
const DOC_EXT = new Set(['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.md', '.csv', '.wps', '.ofd']);
|
|
49
|
+
export function isImageExt(ext) {
|
|
50
|
+
return IMAGE_EXT.has(String(ext || '').toLowerCase());
|
|
51
|
+
}
|
|
52
|
+
/** 按扩展名猜分类;猜不准归「其他」(头像/简历这种语义由调用方显式给)。 */
|
|
53
|
+
export function categoryFromExt(ext) {
|
|
54
|
+
const e = String(ext || '').toLowerCase();
|
|
55
|
+
if (isImageExt(e))
|
|
56
|
+
return 'other';
|
|
57
|
+
if (DOC_EXT.has(e))
|
|
58
|
+
return 'document';
|
|
59
|
+
return 'other';
|
|
60
|
+
}
|
|
61
|
+
// —— 流式输出的 MIME ——
|
|
62
|
+
export const MIME_TYPES = {
|
|
63
|
+
'.html': 'text/html; charset=utf-8',
|
|
64
|
+
'.pdf': 'application/pdf',
|
|
65
|
+
'.doc': 'application/msword',
|
|
66
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
67
|
+
'.xls': 'application/vnd.ms-excel',
|
|
68
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
69
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
70
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
71
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
72
|
+
'.md': 'text/plain; charset=utf-8',
|
|
73
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
74
|
+
'.svg': 'image/svg+xml',
|
|
75
|
+
'.png': 'image/png',
|
|
76
|
+
'.jpg': 'image/jpeg',
|
|
77
|
+
'.jpeg': 'image/jpeg',
|
|
78
|
+
'.webp': 'image/webp',
|
|
79
|
+
'.gif': 'image/gif',
|
|
80
|
+
'.bmp': 'image/bmp',
|
|
81
|
+
'.avif': 'image/avif',
|
|
82
|
+
'.ico': 'image/x-icon',
|
|
83
|
+
'.wps': 'application/octet-stream',
|
|
84
|
+
'.ofd': 'application/octet-stream',
|
|
85
|
+
};
|
|
86
|
+
// Windows 保留设备名,做文件名会让文件无法正常访问。
|
|
87
|
+
const RESERVED_STEMS = new Set(['con', 'prn', 'aux', 'nul',
|
|
88
|
+
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
|
|
89
|
+
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`)]);
|
|
90
|
+
/** 把任意来源的名字收成跨平台可用的单段文件名(保留中文)。 */
|
|
91
|
+
export function safeFileName(name) {
|
|
92
|
+
let s = String(name ?? '')
|
|
93
|
+
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
|
94
|
+
.replace(/\s+/g, ' ')
|
|
95
|
+
.trim()
|
|
96
|
+
.replace(/^\.+/, '');
|
|
97
|
+
if (!s)
|
|
98
|
+
s = 'file';
|
|
99
|
+
const stem = parse(s).name;
|
|
100
|
+
if (RESERVED_STEMS.has(stem.toLowerCase()))
|
|
101
|
+
s = `_${s}`;
|
|
102
|
+
return s.slice(0, 140);
|
|
103
|
+
}
|
|
104
|
+
/** 在台账里为「分类 + 文件名」找一个不冲突的 posix 相对路径(`(2)` 递增)。 */
|
|
105
|
+
function uniqueRel(store, category, fileName, excludeId = null) {
|
|
106
|
+
const { name: stem, ext } = parse(fileName);
|
|
107
|
+
let rel = toPosix(join(category, fileName));
|
|
108
|
+
let n = 1;
|
|
109
|
+
while (store.files.some((r) => r.id !== excludeId && r.rel === rel)) {
|
|
110
|
+
n += 1;
|
|
111
|
+
rel = toPosix(join(category, `${stem} (${n})${ext}`));
|
|
112
|
+
}
|
|
113
|
+
return rel;
|
|
114
|
+
}
|
|
115
|
+
// —— 台账记录 ——
|
|
116
|
+
export function normalizeFileRecord(raw) {
|
|
117
|
+
if (!raw || typeof raw !== 'object' || !raw.id || !raw.rel)
|
|
118
|
+
return null;
|
|
119
|
+
const name = String(raw.name || basename(String(raw.rel)));
|
|
120
|
+
return {
|
|
121
|
+
id: String(raw.id),
|
|
122
|
+
// 分类只是**磁盘分目录**用的内部属性(`files/<分类>/`),不是用户要维护的「槽位」。
|
|
123
|
+
category: CATEGORY_IDS.includes(raw.category) ? raw.category : 'other',
|
|
124
|
+
name,
|
|
125
|
+
rel: toPosix(String(raw.rel)),
|
|
126
|
+
originalName: String(raw.originalName || name),
|
|
127
|
+
ext: String(raw.ext || extname(name).toLowerCase()),
|
|
128
|
+
bytes: Number(raw.bytes) || 0,
|
|
129
|
+
sha256: String(raw.sha256 || ''),
|
|
130
|
+
// 原始绝对路径(posix);Web 上传没有,留空——还原时需显式给目标目录
|
|
131
|
+
sourcePath: String(raw.sourcePath || ''),
|
|
132
|
+
mode: ['move', 'copy', 'upload'].includes(raw.mode) ? raw.mode : 'upload',
|
|
133
|
+
tags: Array.isArray(raw.tags) ? raw.tags.map(String) : [],
|
|
134
|
+
note: String(raw.note || ''),
|
|
135
|
+
createdAt: raw.createdAt || null,
|
|
136
|
+
updatedAt: raw.updatedAt || null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* 一个 `file` 字段的值 → 附件 id 数组(**全仓库唯一的归一化入口**)。
|
|
141
|
+
*
|
|
142
|
+
* 字段可以挂**多个**附件:新写入统一存数组(哪怕只有一个)。要吃下三种历史形态:
|
|
143
|
+
* 单值字符串、id 数组、以及旧版把多个 id **连写成一个字符串**(`"f_a,f_b"`——
|
|
144
|
+
* 0.8.0 之前 `--set` 不拆逗号),所以字符串统一按分隔符拆开。
|
|
145
|
+
* 去空、去重、保序:同一个附件在一个字段里出现两次没有意义。
|
|
146
|
+
*/
|
|
147
|
+
export function fileIdsOf(value) {
|
|
148
|
+
const raw = value === null || value === undefined
|
|
149
|
+
? []
|
|
150
|
+
: (Array.isArray(value) ? value : [value]);
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const x of raw) {
|
|
153
|
+
for (const piece of String(x ?? '').split(/[,,、;;]+/)) {
|
|
154
|
+
const id = piece.trim();
|
|
155
|
+
if (!id || out.includes(id))
|
|
156
|
+
continue;
|
|
157
|
+
out.push(id);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
/** 附件定位:id 优先,其次唯一文件名。 */
|
|
163
|
+
export function findFileRecord(store, ref) {
|
|
164
|
+
const key = String(ref ?? '').trim();
|
|
165
|
+
if (!key)
|
|
166
|
+
return null;
|
|
167
|
+
const byId = store.files.find((r) => r.id === key);
|
|
168
|
+
if (byId)
|
|
169
|
+
return byId;
|
|
170
|
+
const byName = store.files.filter((r) => r.name === key);
|
|
171
|
+
if (byName.length === 1)
|
|
172
|
+
return byName[0];
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* 反向链接:哪些条目的**哪些字段**引用了这个附件。
|
|
177
|
+
*
|
|
178
|
+
* 三种引用方式都要认,否则附件中心会把「只在正文里贴过」的图片、
|
|
179
|
+
* 或「躺在原始资料袋里」的原始文件判成未引用,然后被 cleanup 删掉:
|
|
180
|
+
* 1. `file` 字段 —— 值是附件 id(或唯一文件名),一个字段可挂多个,
|
|
181
|
+
* 按 `fileIdsOf` 逐个成员判断,**任一** id 命中即算。
|
|
182
|
+
* 2. `md` 字段 —— 正文里的 ``(见 mdimages.mdImageIds)。
|
|
183
|
+
* 图片是**内嵌在 markdown 里**的,和 file 字段一样是正式引用。
|
|
184
|
+
* 3. `source` 字段 —— 原始资料袋里 kind='file' 的 item(袋是溯源的锚点:
|
|
185
|
+
* 字段值是从这份原件整理出来的,原件被删,溯源就成了空话)。
|
|
186
|
+
* 同一个(条目, 字段)只出一条。附件中心据此显示「被哪些栏目 / 条目 / 字段用到」。
|
|
187
|
+
*/
|
|
188
|
+
export function fileReferences(store, rec) {
|
|
189
|
+
if (!rec)
|
|
190
|
+
return [];
|
|
191
|
+
// 按 **id** 判定命中,不做对象引用比较(`===`):调用方拿到的往往是
|
|
192
|
+
// listFiles 加工过的副本,引用比较会静默失配 —— 附件就被误判成「未引用」了。
|
|
193
|
+
// 正文里也可能是文件名而不是 id,所以两种解析结果都算命中。
|
|
194
|
+
const targetId = String(rec.id ?? '');
|
|
195
|
+
const targetName = String(rec.name ?? '');
|
|
196
|
+
const hits = (refs) => refs.some((ref) => {
|
|
197
|
+
if (targetId && ref === targetId)
|
|
198
|
+
return true;
|
|
199
|
+
const found = findFileRecord(store, ref);
|
|
200
|
+
if (!found)
|
|
201
|
+
return false;
|
|
202
|
+
return (targetId && found.id === targetId) || (targetName && found.name === targetName);
|
|
203
|
+
});
|
|
204
|
+
const out = [];
|
|
205
|
+
for (const sec of store.sections || []) {
|
|
206
|
+
// 一次栏目只扫一遍条目,别在字段循环里反复全表扫
|
|
207
|
+
const entries = (store.entries || []).filter((e) => e.section === sec.id);
|
|
208
|
+
if (!entries.length)
|
|
209
|
+
continue;
|
|
210
|
+
const refFields = (sec.fields || []).filter(mayReferenceFiles);
|
|
211
|
+
if (!refFields.length)
|
|
212
|
+
continue;
|
|
213
|
+
for (const e of entries) {
|
|
214
|
+
const push = (f) => out.push({
|
|
215
|
+
sectionId: sec.id, sectionTitle: sec.title,
|
|
216
|
+
entryId: e.id, entryTitle: e.title,
|
|
217
|
+
fieldKey: f.key, fieldLabel: f.label,
|
|
218
|
+
});
|
|
219
|
+
// 引用口径由各类型的 `refs` 决定:附件中心的「谁引用了它」**不**把资料袋正文里
|
|
220
|
+
// 内嵌的图算进来(既有行为,P2 不改);导出打包才算(见 transfer.referencedFileIds)。
|
|
221
|
+
for (const f of refFields) {
|
|
222
|
+
const ids = behaviorOf(f.type).refs(f, e.values?.[f.key]);
|
|
223
|
+
if (ids.length && hits(ids))
|
|
224
|
+
push(f);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* 附件中心的分组素材:哪些栏目**可能**引用附件。
|
|
232
|
+
* 中心据此按栏目筛选 / 分组 —— 组织方式**完全由栏目驱动,不预定义任何分类或槽位**。
|
|
233
|
+
* 带 `file` / `md` 字段的栏目都算数:md 字段正文里贴的图也是正式引用;
|
|
234
|
+
* `source` 袋也算——袋里的原始文件是溯源的锚点,同样要能按栏目筛出来。
|
|
235
|
+
*/
|
|
236
|
+
export function collectFileSections(store) {
|
|
237
|
+
return (store.sections || [])
|
|
238
|
+
.filter((s) => (s.fields || []).some(mayReferenceFiles))
|
|
239
|
+
.map((s) => ({ id: s.id, title: s.title }));
|
|
240
|
+
}
|
|
241
|
+
export function fileRecordAbs(rec) {
|
|
242
|
+
return join(filesDir(), rec.rel.split('/').join(sep));
|
|
243
|
+
}
|
|
244
|
+
export function fileLinks(rec) {
|
|
245
|
+
const u = `/api/files/${encodeURIComponent(rec.id)}/content`;
|
|
246
|
+
return { url: u, downloadUrl: `${u}?download=1` };
|
|
247
|
+
}
|
|
248
|
+
/** 对外(CLI / Web)的完整描述:物理路径 + 展示路径 + 链接(+ 可选的反向引用)。 */
|
|
249
|
+
export function describeFile(rec, store = null) {
|
|
250
|
+
const abs = fileRecordAbs(rec);
|
|
251
|
+
return {
|
|
252
|
+
...rec,
|
|
253
|
+
categoryLabel: categoryLabel(rec.category),
|
|
254
|
+
isImage: isImageExt(rec.ext),
|
|
255
|
+
path: abs,
|
|
256
|
+
displayPath: displayPath(abs),
|
|
257
|
+
sourceDisplay: rec.sourcePath ? displayPath(rec.sourcePath) : '',
|
|
258
|
+
usedBy: store ? fileReferences(store, rec) : [],
|
|
259
|
+
...fileLinks(rec),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function buildRecord({ id, category, rel, srcAbs, originalName, ext: e, bytes, sha, mode, tags, note }) {
|
|
263
|
+
return normalizeFileRecord({
|
|
264
|
+
id, category, name: basename(rel), rel,
|
|
265
|
+
originalName: originalName || basename(rel),
|
|
266
|
+
ext: e, bytes, sha256: sha,
|
|
267
|
+
sourcePath: srcAbs ? toPosix(srcAbs) : '',
|
|
268
|
+
mode,
|
|
269
|
+
tags: Array.isArray(tags) ? tags.map(String) : [],
|
|
270
|
+
note: String(note || ''),
|
|
271
|
+
createdAt: nowIso(), updatedAt: nowIso(),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* 从本机路径导入。默认**移动**,copy=true 才复制。
|
|
276
|
+
* dryRun 只做读取与落点计算,不碰目标位置。
|
|
277
|
+
*/
|
|
278
|
+
export async function importLocalFile(store, opts = {}) {
|
|
279
|
+
const { path: src, category, copy, tags, note, name, dryRun } = opts;
|
|
280
|
+
if (!src)
|
|
281
|
+
throw badInput('缺少文件路径:--path <本机文件路径>');
|
|
282
|
+
const srcAbs = resolve(String(src));
|
|
283
|
+
let st;
|
|
284
|
+
try {
|
|
285
|
+
st = await fsp.stat(srcAbs);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
throw badInput(`读不到文件: ${displayPath(srcAbs)}`);
|
|
289
|
+
}
|
|
290
|
+
if (!st.isFile())
|
|
291
|
+
throw badInput(`不是文件: ${displayPath(srcAbs)}`);
|
|
292
|
+
if (isInside(filesDir(), srcAbs)) {
|
|
293
|
+
throw badInput('该文件已经在附件库目录里,无需重复导入');
|
|
294
|
+
}
|
|
295
|
+
const sha = await hashFile(srcAbs);
|
|
296
|
+
const dup = store.files.find((r) => r.sha256 === sha);
|
|
297
|
+
if (dup)
|
|
298
|
+
return { record: dup, duplicate: true, dryRun: false };
|
|
299
|
+
const e = extname(srcAbs).toLowerCase();
|
|
300
|
+
const cat = category || categoryFromExt(e);
|
|
301
|
+
const fileName = safeFileName(name || basename(srcAbs));
|
|
302
|
+
const rel = uniqueRel(store, cat, fileName);
|
|
303
|
+
const record = buildRecord({
|
|
304
|
+
id: newId('f'), category: cat, rel, srcAbs, originalName: basename(srcAbs),
|
|
305
|
+
ext: e, bytes: st.size, sha, mode: copy ? 'copy' : 'move', tags, note,
|
|
306
|
+
});
|
|
307
|
+
if (dryRun)
|
|
308
|
+
return { record, duplicate: false, dryRun: true };
|
|
309
|
+
const dst = join(filesDir(), rel.split('/').join(sep));
|
|
310
|
+
await ensureDir(dirname(dst));
|
|
311
|
+
if (copy) {
|
|
312
|
+
await fsp.copyFile(srcAbs, dst);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
try {
|
|
316
|
+
await fsp.rename(srcAbs, dst);
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
// EXDEV:跨卷 rename 不被支持,退成「复制 + 删原件」,语义不变
|
|
320
|
+
if (err && err.code === 'EXDEV') {
|
|
321
|
+
await fsp.copyFile(srcAbs, dst);
|
|
322
|
+
await fsp.unlink(srcAbs);
|
|
323
|
+
}
|
|
324
|
+
else
|
|
325
|
+
throw err;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { record, duplicate: false, dryRun: false, moved: !copy };
|
|
329
|
+
}
|
|
330
|
+
function decodeBase64(content) {
|
|
331
|
+
let b64 = String(content || '');
|
|
332
|
+
const comma = b64.indexOf(',');
|
|
333
|
+
if (b64.startsWith('data:') && comma >= 0)
|
|
334
|
+
b64 = b64.slice(comma + 1);
|
|
335
|
+
let buf;
|
|
336
|
+
try {
|
|
337
|
+
buf = Buffer.from(b64, 'base64');
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
throw badInput('上传内容不是合法的 base64');
|
|
341
|
+
}
|
|
342
|
+
if (!buf.length)
|
|
343
|
+
throw badInput('上传内容为空');
|
|
344
|
+
return buf;
|
|
345
|
+
}
|
|
346
|
+
/** Web 上传:base64(或 data URL)内容直接写进库。 */
|
|
347
|
+
export async function importContent(store, opts = {}) {
|
|
348
|
+
const { content, name, category, tags, note, dryRun } = opts;
|
|
349
|
+
if (content === undefined || content === null)
|
|
350
|
+
throw badInput('缺少上传内容');
|
|
351
|
+
if (!name)
|
|
352
|
+
throw badInput('上传必须带文件名');
|
|
353
|
+
const buf = decodeBase64(content);
|
|
354
|
+
const sha = createHash('sha256').update(buf).digest('hex');
|
|
355
|
+
const dup = store.files.find((r) => r.sha256 === sha);
|
|
356
|
+
if (dup)
|
|
357
|
+
return { record: dup, duplicate: true, dryRun: false };
|
|
358
|
+
const originalName = safeFileName(name);
|
|
359
|
+
const e = extname(originalName).toLowerCase();
|
|
360
|
+
const cat = category || categoryFromExt(e);
|
|
361
|
+
const rel = uniqueRel(store, cat, originalName);
|
|
362
|
+
const record = buildRecord({
|
|
363
|
+
id: newId('f'), category: cat, rel, srcAbs: null, originalName,
|
|
364
|
+
ext: e, bytes: buf.length, sha, mode: 'upload', tags, note,
|
|
365
|
+
});
|
|
366
|
+
if (dryRun)
|
|
367
|
+
return { record, duplicate: false, dryRun: true };
|
|
368
|
+
const dst = join(filesDir(), rel.split('/').join(sep));
|
|
369
|
+
await ensureDir(dirname(dst));
|
|
370
|
+
await fsp.writeFile(dst, buf);
|
|
371
|
+
return { record, duplicate: false, dryRun: false, moved: false };
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* 改分类 / 文件名:物理文件先搬到新落点,返回新记录(由 service 在事务里落台账)。
|
|
375
|
+
*/
|
|
376
|
+
export async function relocateFile(store, rec, { category, name }) {
|
|
377
|
+
const cat = category || rec.category;
|
|
378
|
+
// 改名时**不带扩展名**就把原扩展名接回去——否则物理文件丢掉 .jpg/.pdf,
|
|
379
|
+
// 一切按扩展名认类型的地方(内联预览 / Content-Type / 系统默认程序打开 /
|
|
380
|
+
// 历史迁移 / 导入还原出来的文件)都会哑掉,而台账里的 ext 字段还骗人说没丢。
|
|
381
|
+
let wanted = name || rec.name;
|
|
382
|
+
const originalExt = rec.ext || extname(rec.name).toLowerCase();
|
|
383
|
+
if (name && originalExt && !extname(wanted))
|
|
384
|
+
wanted += originalExt;
|
|
385
|
+
const fileName = safeFileName(wanted);
|
|
386
|
+
const same = cat === rec.category && fileName === rec.name;
|
|
387
|
+
const rel = same ? rec.rel : uniqueRel(store, cat, fileName, rec.id);
|
|
388
|
+
if (!same) {
|
|
389
|
+
const oldAbs = fileRecordAbs(rec);
|
|
390
|
+
const newAbs = join(filesDir(), rel.split('/').join(sep));
|
|
391
|
+
await ensureDir(dirname(newAbs));
|
|
392
|
+
await fsp.copyFile(oldAbs, newAbs);
|
|
393
|
+
await fsp.unlink(oldAbs);
|
|
394
|
+
}
|
|
395
|
+
return normalizeFileRecord({ ...rec, category: cat, name: basename(rel), rel, updatedAt: nowIso() });
|
|
396
|
+
}
|
|
397
|
+
/** 删除物理原件;文件本就不存在视为成功(台账是权威)。 */
|
|
398
|
+
export async function deletePhysical(rec) {
|
|
399
|
+
try {
|
|
400
|
+
await fsp.unlink(fileRecordAbs(rec));
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
if (err && err.code === 'ENOENT')
|
|
404
|
+
return;
|
|
405
|
+
throw err;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function nonClashPath(dir, fileName) {
|
|
409
|
+
const { name: stem, ext } = parse(fileName);
|
|
410
|
+
let dst = join(dir, fileName);
|
|
411
|
+
let n = 1;
|
|
412
|
+
const exists = async (p) => { try {
|
|
413
|
+
await fsp.access(p);
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
return false;
|
|
418
|
+
} };
|
|
419
|
+
return (async () => {
|
|
420
|
+
while (await exists(dst)) {
|
|
421
|
+
n += 1;
|
|
422
|
+
dst = join(dir, `${stem} (${n})${ext}`);
|
|
423
|
+
}
|
|
424
|
+
return dst;
|
|
425
|
+
})();
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* 还原 / 导出:把附件复制到目标位置;copy=false(默认)时再删掉库内原件
|
|
429
|
+
* (由 service 负责撤销台账记录)。
|
|
430
|
+
*/
|
|
431
|
+
export async function restoreFile(store, rec, { to, copy } = {}) {
|
|
432
|
+
const targetDir = resolve(String(to || (rec.sourcePath ? dirname(rec.sourcePath) : '')));
|
|
433
|
+
if (!targetDir || targetDir === sep) {
|
|
434
|
+
throw badInput('该附件没有原始位置,需要给目标目录:--to <目录>');
|
|
435
|
+
}
|
|
436
|
+
const dst = await nonClashPath(targetDir, rec.name);
|
|
437
|
+
if (!copy && isInside(filesDir(), dst)) {
|
|
438
|
+
throw badInput('还原落点在附件库目录内,会自己覆盖自己;换一个目录');
|
|
439
|
+
}
|
|
440
|
+
await ensureDir(targetDir);
|
|
441
|
+
await fsp.copyFile(fileRecordAbs(rec), dst);
|
|
442
|
+
if (!copy)
|
|
443
|
+
await fsp.unlink(fileRecordAbs(rec));
|
|
444
|
+
return { path: dst, displayPath: displayPath(dst), kept: copy === true };
|
|
445
|
+
}
|
|
446
|
+
/** 以正确的 Content-Type 把附件流式输出给浏览器;download=true 时强制下载。 */
|
|
447
|
+
export function streamRecord(res, rec, { download = false } = {}) {
|
|
448
|
+
const file = fileRecordAbs(rec);
|
|
449
|
+
return new Promise((resolve, reject) => {
|
|
450
|
+
const rs = createReadStream(file);
|
|
451
|
+
rs.once('error', reject);
|
|
452
|
+
rs.once('open', () => {
|
|
453
|
+
res.writeHead(200, {
|
|
454
|
+
'content-type': MIME_TYPES[rec.ext] || 'application/octet-stream',
|
|
455
|
+
'content-disposition': `${download ? 'attachment' : 'inline'}; filename*=UTF-8''${encodeURIComponent(rec.name)}`,
|
|
456
|
+
'cache-control': 'no-cache',
|
|
457
|
+
});
|
|
458
|
+
rs.pipe(res);
|
|
459
|
+
rs.once('end', resolve);
|
|
460
|
+
rs.once('error', reject);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
}
|