weflow-cli 1.11.0 → 1.11.1
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 +38 -0
- package/OPERATIONS.md +7 -1
- package/bin/weflow-cli.ts +7 -0
- package/dist/bin/weflow-cli.js +8 -0
- package/dist/bin/weflow-cli.js.map +1 -1
- package/docs/RELEASING.md +33 -0
- package/package.json +1 -1
- package/scripts/export_chat_html.py +117 -15
package/docs/RELEASING.md
CHANGED
|
@@ -21,6 +21,19 @@ npm 包与 GitHub 分开发布,中间隔多久都不奇怪——`1.5.0` 与主
|
|
|
21
21
|
已备好但**从未发布**的版本号可以并进下一个:`1.5.1` 准备过却没发,
|
|
22
22
|
它的修复就直接并进了 `1.6.0`。
|
|
23
23
|
|
|
24
|
+
**改 `package.json` 的版本号时必须同时改 `package-lock.json`**(lockfileVersion 3 里有两个地方:
|
|
25
|
+
顶层的 `version` 与 `packages[""].version`)。**2026-10-13 发 1.11.0 时就是这么红了一次**:包已经发出去、
|
|
26
|
+
tag 也打了,`release-consistency` 才报「版本号与锁文件必须一致」—— 我本地只跑了 build/test/文档命令检查,
|
|
27
|
+
而这三样都不看锁文件。发布物本身没问题(`files` 白名单里没有锁文件),但 master 的 CI 红了。
|
|
28
|
+
|
|
29
|
+
```powershell
|
|
30
|
+
node -p "require('./package.json').version" # 两个必须相等
|
|
31
|
+
node -p "require('./package-lock.json').version"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
顺手验一下锁文件没跑偏(只写锁、不动 `node_modules`):
|
|
35
|
+
`npm install --package-lock-only --dry-run` —— 报 `up to date` 即可,那也正是 `npm ci` 会不会炸的判据。
|
|
36
|
+
|
|
24
37
|
## 2. 整理 CHANGELOG
|
|
25
38
|
|
|
26
39
|
- 把 `## Unreleased` 改名为 `## <新版本号>`
|
|
@@ -229,6 +242,26 @@ npm view weflow-cli version --registry=https://registry.npmjs.org # 用官方
|
|
|
229
242
|
3cf5605,它不在包里那一份 tarball 的对应关系里 —— 写下来是因为 1.8.0 那次正是"事后不知道 tag 对应哪个
|
|
230
243
|
提交")。GitHub Release `v1.10.0` 同样指向它,正文由 `## 1.10.0` 段机械切出(92,213 字符,未触及
|
|
231
244
|
125,000 上限 ⇒ 未截断),List 上已是 **Latest**。
|
|
245
|
+
- **1.11.0 的出处核过了(2026-10-13,发布当天,四种证据)**:
|
|
246
|
+
1. 本地 `npm pack` 的 shasum = `21db27cbb151987331c7993c625eb4626c23f58a`,与**两个源**上
|
|
247
|
+
`weflow-cli@1.11.0` 的 `dist.shasum` **三者相同** ⇒ 发出去的就是我构建的那一份,且镜像同步的是同一份;
|
|
248
|
+
2. 从 registry(不是本地 tarball)装进一个**空目录**,用**装好的那份**跑 `cli.cjs --version` 得到 `1.11.0`、
|
|
249
|
+
`--help` 72 行含 `Commands:`;1.9.0 缺过的 `logo.png` 与 `scripts/data/wechat_emoji.json` 都在,
|
|
250
|
+
开发用探针 `.mjs` 不在包里(有意,见 `test/pack-contents.test.ts`);
|
|
251
|
+
3. **本次真正改动的那个脚本逐字节核过**:装好的那份 `scripts/export_chat_html.py` 的 sha256
|
|
252
|
+
= `73117B7A35347D68FE239D660C9A0531E231BA8CA7A0A31F8DA0C07D5D54C041`,与仓库里那份**相同**
|
|
253
|
+
⇒ 用户拿到的是这一版脚本,不是旧的。这是比"文件在不在"更强的一层:**在,而且是我这次写的那份**;
|
|
254
|
+
4. 两个源都是 1.11.0:官方源与 `registry.npmmirror.com`(`curl -X PUT …/syncs` 受理后,发布后约 1 分钟
|
|
255
|
+
查 shasum 仍取不到、20 分钟内再查时两源都已生效 —— 这次的窗口比 1.10.0 那次(约 4 分钟)长,
|
|
256
|
+
但**没有逐点采样**,所以只记"两次查询的区间",不写一个假的分钟数)。
|
|
257
|
+
**发布日志 0 条告警**;**tag 打在被发布的那个提交上**:`v1.11.0` → `bd82316`(`821479b` 是功能提交,
|
|
258
|
+
`bd82316` 只改了 `package.json` 版本号与 CHANGELOG 段名)。GitHub Release `v1.11.0` 指向 `bd82316`,
|
|
259
|
+
正文由 `## 1.11.0` 段机械切出(9,689 字符、7 条 bullet、未触及 125,000 上限 ⇒ 未截断),
|
|
260
|
+
建完在 List 上已是 **Latest**(把 1.10.0 挤下去是预期行为)。
|
|
261
|
+
⚠️ **这次发布本身有一次 CI 失败,记在这里免得事后只看到"一切正常"**:`chore: 1.11.0` 那次提交的
|
|
262
|
+
`release-consistency` 报「版本号与锁文件必须一致」—— 我改了 `package.json` 忘了 `package-lock.json`,
|
|
263
|
+
连红三次(含紧随其后的文档提交)。**发布物不受影响**(白名单里没有锁文件、shasum 与装出来的那份都验过),
|
|
264
|
+
但 master 的 CI 是红的;修在 `f9e55c8`,之后两次推送都是 success。清单第 1 步已补上这条检查。
|
|
232
265
|
|
|
233
266
|
|
|
234
267
|
## 发布后
|
package/package.json
CHANGED
|
@@ -14,6 +14,7 @@ import re
|
|
|
14
14
|
import base64
|
|
15
15
|
import urllib.request
|
|
16
16
|
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
17
18
|
import concurrent.futures
|
|
18
19
|
import shutil
|
|
19
20
|
import struct
|
|
@@ -539,6 +540,10 @@ def scan_account_file_index(account_dir):
|
|
|
539
540
|
except OSError:
|
|
540
541
|
continue
|
|
541
542
|
index.setdefault((normalize_asset_name(fname), size), []).append(path)
|
|
543
|
+
# 排序是为了**可复现**:多候选且消息没带 md5 时取 `[0]`,而 `os.walk` 的顺序不保证稳定 ——
|
|
544
|
+
# 不排的话,同一次导出在两台机器上可能落到不同名字的副本上。
|
|
545
|
+
for paths in index.values():
|
|
546
|
+
paths.sort()
|
|
542
547
|
return index
|
|
543
548
|
|
|
544
549
|
|
|
@@ -2448,19 +2453,77 @@ p {{ margin:8px 0; font-size:14px; }}
|
|
|
2448
2453
|
</html>'''
|
|
2449
2454
|
|
|
2450
2455
|
|
|
2456
|
+
_WINDOWS_RESERVED_NAMES = frozenset(
|
|
2457
|
+
['con', 'prn', 'aux', 'nul']
|
|
2458
|
+
+ ['com%d' % index for index in range(1, 10)]
|
|
2459
|
+
+ ['lpt%d' % index for index in range(1, 10)])
|
|
2460
|
+
|
|
2461
|
+
|
|
2462
|
+
def _clean_asset_component(text):
|
|
2463
|
+
"""把一段文件名清洗到"任何 Windows 上都能创建"。
|
|
2464
|
+
|
|
2465
|
+
在 `sanitize_filename` 之外多管一件会**静默改结果**的事:首尾的空白与点(Windows 会把 `a .` 存成 `a`,
|
|
2466
|
+
于是清单里的名字和磁盘上的名字对不上)。
|
|
2467
|
+
"""
|
|
2468
|
+
return sanitize_filename(text).strip().strip('.')
|
|
2469
|
+
|
|
2470
|
+
|
|
2451
2471
|
def _asset_file_name(title, used_names, source, md5):
|
|
2452
2472
|
"""落盘的文档文件名:保持原样(只清洗非法字符),重名才加 md5 前 8 位。
|
|
2453
2473
|
|
|
2454
2474
|
不改名是刻意的 —— 那是用户自己的文件名,读者要靠它认东西;微信自己也会加 `(1)`。但两个不同来源
|
|
2455
2475
|
的文件可能同名,那种情况必须区分,否则后来者会覆盖先到者、清单指错文件。
|
|
2476
|
+
|
|
2477
|
+
**截断要在扩展名之前**:真机实测过一个超长标题,80 字符上限把 `.pdf` 一起切掉了,落盘成没有扩展名的
|
|
2478
|
+
文件 —— 系统与编辑器都不知道该用什么打开它。
|
|
2456
2479
|
"""
|
|
2457
|
-
|
|
2480
|
+
stem, ext = os.path.splitext(str(title or ''))
|
|
2481
|
+
# 扩展名单独保留(也过一遍清洗),但不参与"给主干剩下的额度";异常长的"扩展名"当没扩展名处理
|
|
2482
|
+
ext = _clean_asset_component(ext).replace('.', '')
|
|
2483
|
+
if len(ext) > 16:
|
|
2484
|
+
stem, ext = str(title or ''), ''
|
|
2485
|
+
stem = _clean_asset_component(stem)
|
|
2486
|
+
room = max(1, 80 - (len(ext) + 1 if ext else 0))
|
|
2487
|
+
base = stem[:room] or 'file'
|
|
2488
|
+
if base.lower() in _WINDOWS_RESERVED_NAMES:
|
|
2489
|
+
# `CON.pdf` 这类在 Windows 上打不开(会被当成设备名)—— 加个前缀就正常了
|
|
2490
|
+
base = '_' + base
|
|
2491
|
+
safe = base + ('.' + ext if ext else '')
|
|
2458
2492
|
if used_names.get(safe) in (None, source):
|
|
2459
2493
|
return safe
|
|
2460
2494
|
stem, ext = os.path.splitext(safe)
|
|
2461
2495
|
return '%s_%s%s' % (stem, (md5 or 'copy')[:8], ext)
|
|
2462
2496
|
|
|
2463
2497
|
|
|
2498
|
+
def _load_existing_manifest(manifest_path, out_dir):
|
|
2499
|
+
"""读回同目录里已有的清单条目(v1 的裸字符串也认),丢掉**已经不在磁盘上**的那些。
|
|
2500
|
+
|
|
2501
|
+
为什么不是直接覆盖:清单住在 `<会话>_assets/` 这个**共享**目录里,而导出可以分批做
|
|
2502
|
+
(`--limit 200` 一次、整段再一次;md 一次、`json --media` 再一次)。覆盖会让窄窗口那次的清单把宽窗口
|
|
2503
|
+
那次的条目**删掉**,而 md 正文里的链接还在 —— 之后任何"读清单带媒体"的导出都会少一批,
|
|
2504
|
+
且两边都不报错。2026-10-13 真机就是这么撞上的:整段导出的 585 条被 `--limit 600` 那次的 150 条盖掉。
|
|
2505
|
+
"""
|
|
2506
|
+
try:
|
|
2507
|
+
with open(manifest_path, 'r', encoding='utf-8') as handle:
|
|
2508
|
+
data = json.load(handle)
|
|
2509
|
+
except (OSError, ValueError):
|
|
2510
|
+
return {}
|
|
2511
|
+
messages = data.get('messages') if isinstance(data, dict) else None
|
|
2512
|
+
if not isinstance(messages, dict):
|
|
2513
|
+
return {}
|
|
2514
|
+
kept = {}
|
|
2515
|
+
for key, value in messages.items():
|
|
2516
|
+
if isinstance(value, str):
|
|
2517
|
+
record = {'path': value, 'kind': 'image'}
|
|
2518
|
+
elif isinstance(value, dict) and isinstance(value.get('path'), str):
|
|
2519
|
+
record = value
|
|
2520
|
+
else:
|
|
2521
|
+
continue
|
|
2522
|
+
if os.path.isfile(os.path.join(out_dir, record['path'].replace('/', os.sep))):
|
|
2523
|
+
kept[key] = record
|
|
2524
|
+
return kept
|
|
2525
|
+
|
|
2526
|
+
|
|
2464
2527
|
def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
|
|
2465
2528
|
"""把媒体**落盘**并把 `manifest.json` 写好;返回 `(assets 目录名, manifest, 新写文件数)`。
|
|
2466
2529
|
|
|
@@ -2477,7 +2540,9 @@ def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
|
|
|
2477
2540
|
assets = assets_dir or os.path.join(out_dir, file_prefix + '_assets')
|
|
2478
2541
|
os.makedirs(assets, exist_ok=True)
|
|
2479
2542
|
|
|
2480
|
-
|
|
2543
|
+
# 与已有清单**合并**:分批导出时,窄窗口那一次不该把宽窗口那次的条目删掉(见 `_load_existing_manifest`)。
|
|
2544
|
+
manifest_path = os.path.join(assets, 'manifest.json')
|
|
2545
|
+
manifest = _load_existing_manifest(manifest_path, out_dir)
|
|
2481
2546
|
written = 0
|
|
2482
2547
|
for item in formatted:
|
|
2483
2548
|
blob = item.get('image_b64')
|
|
@@ -2502,7 +2567,6 @@ def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
|
|
|
2502
2567
|
'kind': 'image',
|
|
2503
2568
|
}
|
|
2504
2569
|
|
|
2505
|
-
file_exported = 0
|
|
2506
2570
|
file_written = 0
|
|
2507
2571
|
file_unavailable = 0
|
|
2508
2572
|
files_dir = os.path.join(assets, _FILE_ASSET_SUBDIR)
|
|
@@ -2526,13 +2590,18 @@ def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
|
|
|
2526
2590
|
info.get('md5'))
|
|
2527
2591
|
target = os.path.join(files_dir, name)
|
|
2528
2592
|
if used_names.get(name) != source:
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2593
|
+
try:
|
|
2594
|
+
os.makedirs(files_dir, exist_ok=True)
|
|
2595
|
+
if not os.path.exists(target):
|
|
2596
|
+
with open(target, 'wb') as dst, open(source, 'rb') as src:
|
|
2597
|
+
shutil.copyfileobj(src, dst)
|
|
2598
|
+
file_written += 1
|
|
2599
|
+
except OSError:
|
|
2600
|
+
# 一个文件拷不进去(名字太怪、路径过长、目标被占用)不能让**整次导出**在最后一步崩掉 ——
|
|
2601
|
+
# 前面几分钟的解析与图片抽取都在,代价只是这一份文档缺失,且它会计入 unavailable。
|
|
2602
|
+
file_unavailable += 1
|
|
2603
|
+
continue
|
|
2534
2604
|
used_names[name] = source
|
|
2535
|
-
file_exported += 1
|
|
2536
2605
|
manifest['%s:%s' % (item.get('local_id'), item.get('create_time'))] = {
|
|
2537
2606
|
'path': os.path.relpath(target, out_dir).replace('\\', '/'),
|
|
2538
2607
|
'kind': 'file',
|
|
@@ -2540,16 +2609,38 @@ def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
|
|
|
2540
2609
|
'bytes': size,
|
|
2541
2610
|
}
|
|
2542
2611
|
|
|
2612
|
+
# `exported` 数的是**合并后清单里真实存在的文档条目**(第二遍跑同一目录时它不为 0,而新拷贝是 0);
|
|
2613
|
+
# `copied` 才是"这次新拷了几个"。两个都写出来,免得读清单的人把后者当前者。
|
|
2614
|
+
file_exported = sum(1 for record in manifest.values() if record.get('kind') == 'file')
|
|
2543
2615
|
FILE_ASSET_STATE['exported'] = file_exported
|
|
2544
2616
|
FILE_ASSET_STATE['written'] = file_written
|
|
2545
2617
|
FILE_ASSET_STATE['unavailable'] = file_unavailable
|
|
2546
|
-
with open(
|
|
2618
|
+
with open(manifest_path, 'w', encoding='utf-8') as fh:
|
|
2547
2619
|
json.dump({'schema': 'weflow-media-manifest/v2', 'messages': manifest,
|
|
2548
|
-
'documents': {'exported': file_exported, '
|
|
2620
|
+
'documents': {'exported': file_exported, 'copied': file_written,
|
|
2621
|
+
'unavailable': file_unavailable}},
|
|
2549
2622
|
fh, ensure_ascii=False, indent=2)
|
|
2550
2623
|
return os.path.basename(os.path.normpath(assets)), manifest, written + file_written
|
|
2551
2624
|
|
|
2552
2625
|
|
|
2626
|
+
def _markdown_link_target(rel):
|
|
2627
|
+
"""把相对路径转成 markdown 链接目标:只编码会**截断链接**的那些字符。
|
|
2628
|
+
|
|
2629
|
+
真机实测(2026-10-13,重导咸鱼梦想家时核对产物才发现):文件名叫
|
|
2630
|
+
`…研究生学位(毕业)论文开题报告(2).pdf`,写出来是 `[名字](…报告(2).pdf)` —— 任何 markdown 渲染器
|
|
2631
|
+
都在第一个半角 `)` 处结束目标,链接指向 `…报告(2`,点开是空的。带空格的文件名同理(目标在空白处结束)。
|
|
2632
|
+
所以按段编码这几个字符(括号、空白、`<`、`>`、`[`、`]`、`#`、`?`、`%`、引号、反斜杠),
|
|
2633
|
+
**中文保持原样**:本地预览器都按 UTF-8 解路径,编码成 `%E5%…` 反而让人看不懂。
|
|
2634
|
+
"""
|
|
2635
|
+
out = []
|
|
2636
|
+
for ch in str(rel or '').replace('\\', '/'):
|
|
2637
|
+
if ch.isspace() or ch in '()[]<>#"\'%?\\':
|
|
2638
|
+
out.append('%%%02X' % ord(ch))
|
|
2639
|
+
else:
|
|
2640
|
+
out.append(ch)
|
|
2641
|
+
return ''.join(out)
|
|
2642
|
+
|
|
2643
|
+
|
|
2553
2644
|
def write_markdown(out_dir, file_prefix, formatted, assets_dir=''):
|
|
2554
2645
|
"""markdown 正文 + 媒体落盘(落盘那半在 `write_assets`,两者共用同一份清单)。"""
|
|
2555
2646
|
assets_name, manifest, written = write_assets(out_dir, file_prefix, formatted, assets_dir)
|
|
@@ -2573,9 +2664,15 @@ def write_markdown(out_dir, file_prefix, formatted, assets_dir=''):
|
|
|
2573
2664
|
if rel and kind == 'file':
|
|
2574
2665
|
# 文档不是图片:`` 会被当成坏图,要写成链接,链接文字用原始文件名。
|
|
2575
2666
|
keep = '' if (not body or body.startswith('[文件]')) else body + chr(10) + chr(10)
|
|
2576
|
-
|
|
2667
|
+
# 链接文字用原始文件名;`[`/`]` 换成实体(`[草稿] 方案.docx` 会把链接本身的结构打断),
|
|
2668
|
+
# 控制字符去掉(标题里可能带换行/制表符,落盘名已去掉,文字也得跟上,否则渲染出来断开)
|
|
2669
|
+
text = ''.join(ch for ch in (label or os.path.basename(rel))
|
|
2670
|
+
if ord(ch) >= 32 and ord(ch) != 127)
|
|
2671
|
+
text = text.replace('[', '[').replace(']', ']')
|
|
2672
|
+
body = keep + '[%s](%s)' % (text, _markdown_link_target(rel))
|
|
2577
2673
|
elif rel:
|
|
2578
|
-
body = ('' if (not body or body == '[图片]') else body + chr(10) + chr(10))
|
|
2674
|
+
body = ('' if (not body or body == '[图片]') else body + chr(10) + chr(10)) \
|
|
2675
|
+
+ '' % _markdown_link_target(rel)
|
|
2579
2676
|
lines.extend(['**%s** %s' % (who, stamp), '', body.strip() or '(空)', ''])
|
|
2580
2677
|
|
|
2581
2678
|
md_path = os.path.join(out_dir, file_prefix + '.md')
|
|
@@ -2924,8 +3021,13 @@ def _phase(label):
|
|
|
2924
3021
|
|
|
2925
3022
|
|
|
2926
3023
|
def sanitize_filename(name: str) -> str:
|
|
2927
|
-
"""Remove characters unsafe for filenames.
|
|
2928
|
-
|
|
3024
|
+
"""Remove characters unsafe for filenames.
|
|
3025
|
+
|
|
3026
|
+
控制字符也要去掉:它们不在"非法字符"那张表里,但 Windows 创建时会直接
|
|
3027
|
+
`OSError: Invalid argument`(2026-10-13 实测到一个带制表符的标题把整个导出搞崩)。
|
|
3028
|
+
"""
|
|
3029
|
+
kept = ''.join(ch for ch in str(name or '') if ord(ch) >= 32 and ord(ch) != 127)
|
|
3030
|
+
return re.sub(r'[\\/:*?"<>|]', '_', kept)[:80]
|
|
2929
3031
|
|
|
2930
3032
|
|
|
2931
3033
|
if __name__ == '__main__':
|