diff --git a/.claude/skills/dootask-release/SKILL.md b/.claude/skills/dootask-release/SKILL.md index 5a0fd9d71..d78502c33 100644 --- a/.claude/skills/dootask-release/SKILL.md +++ b/.claude/skills/dootask-release/SKILL.md @@ -1,6 +1,6 @@ --- name: dootask-release -description: 从 `pro` 分支发布 DooTask 前端新版本:刚性顺序流程 translate → version → build → commit → push,前置检查 + 每步确认、失败即停。 +description: 从 `pro` 分支发布 DooTask 前端新版本:翻译 → 版本号/更新日志 → 构建 → 提交推送,刚性顺序、每步确认、失败即停。 --- # DooTask 发布流程 @@ -9,7 +9,7 @@ description: 从 `pro` 分支发布 DooTask 前端新版本:刚性顺序流程 ## 核心原则 -**违反字面规则 = 违反流程精神。** 不要擅自增加、省略、合并或重排步骤。 +按固定顺序执行,不增删、合并或重排步骤。翻译(Step 1)和更新日志(Step 2)由你直接产出;脚本只做确定性机械工作(算版本号、检测差异、字节级生成语言文件)。 ## 前置检查(全部通过才能继续) @@ -17,7 +17,8 @@ description: 从 `pro` 分支发布 DooTask 前端新版本:刚性顺序流程 1. **分支**:必须是 `pro`,否则停止,提示用户切换 2. **工作区**:`git status` 必须干净(无未提交变更、无未跟踪文件),否则**停止**并交由用户处理 -3. **Node.js**:必须 ≥ 20,否则停止 +3. **Node.js**:`node --version` 必须 ≥ 20 +4. **PHP**:`php --version` 必须可用(Step 1 的脚本依赖本地 php,无需容器)。若 host 无 php,停止并提示用户 检查通过后汇报结果,用户确认后再开始执行。 @@ -25,29 +26,140 @@ description: 从 `pro` 分支发布 DooTask 前端新版本:刚性顺序流程 **每步执行前**向用户确认;**每步执行后**报告结果。 -### Step 1: 翻译 -```shell -npm run translate -``` -更新多语言翻译文件。 +开始前先把这份清单复制到你的回复里,逐项勾选、跟踪进度: -### Step 2: 版本号 -```shell -npm run version ``` -更新版本号。 +发布进度: +- [ ] 前置检查(分支 pro / 工作区干净 / node≥20 / php 可用) +- [ ] Step 1 翻译(diff → 翻译 → apply → generate) +- [ ] Step 2 版本号 + CHANGELOG +- [ ] Step 3 构建(./cmd prod) +- [ ] 汇总变更 → 用户确认 → commit + push +- [ ] 确认 GitHub Actions Publish 工作流 success +``` + +--- + +### Step 1: 翻译 + +多语言数据流:`language/original-{web,api}.txt`(原文/简体中文)→ 经翻译写入 `language/translate.json`(含 9 种语言)→ 生成 `public/language/{web,api}/*`。 + +**1.1 检测差异** + +```shell +php .claude/skills/dootask-release/scripts/language.php diff +``` + +输出 JSON: +- `regexErrorCount > 0`:translate.json **已有条目**的占位符与某语言值不一致 → **停止**,报告 `regexErrors`,交用户修复(这是历史数据问题,不要自行猜测修改) +- `redundantCount > 0`:translate.json 里有、但原文已删除的条目 → 仅作提示(apply 时会自动剔除,不致命) +- `needsCount == 0`:无新文案 → **跳到 1.4 直接生成** +- `needsCount > 0`:`needs` 数组即待翻译清单,每项 `key` 已转成占位符形式(如 `(%T1)`)→ 进入 1.2 + +**1.2 翻译** + +对 `needs` 里的每个 `key`,翻成 8 种语言(`zh` 留空、`key` 原样保留):`zh-CHT` `en` `ko` `ja` `de` `fr` `id` `ru`。 + +要求:贴合「项目任务管理系统」语境;占位符 `(%T1)`/`(%M1)` 等原样保留、不可增删改,位置可随目标语言语序调整: + +| 原文 | 翻成英语 | +|---|---| +| (%T1)的周报[(%T2)][(%T3)月第(%T4)周] | Weekly report of (%T1) [(%T2)] [Week (%T4) of month (%T3)] | +| (%T1)提交的「(%M2)」待你审批 | '(%M2)' submitted by (%T1) is waiting for your approval | + +把结果写成一个 JSON 数组文件(建议放 `/tmp/dootask-release-translated.json`,避免污染工作区),每个元素含全部 10 个字段,顺序为: +`key, zh, zh-CHT, en, ko, ja, de, fr, id, ru`(`zh` 写 `""`)。 + +```json +[ + {"key":"...(%T1)...","zh":"","zh-CHT":"...","en":"...","ko":"...","ja":"...","de":"...","fr":"...","id":"...","ru":"..."} +] +``` + +**1.3 合并进 translate.json** + +```shell +php .claude/skills/dootask-release/scripts/language.php apply /tmp/dootask-release-translated.json +``` + +脚本会校验字段完整性与占位符完整性、追加新条目、剔除冗余项,并按项目原生格式写回 `translate.json`。任一条不合格会报错停止,按提示修正翻译后重试。 + +**1.4 生成前端/后端语言文件** + +```shell +php .claude/skills/dootask-release/scripts/language.php generate +``` + +由 `translate.json` 字节级重新生成 `public/language/web/*.js` 与 `public/language/api/*.json`(排序/转义与项目原生工具完全一致,正常情况下 diff 只包含本次新增条目)。 + +**1.5 报告**:用 `git status --short language public/language` 汇总本步改动,向用户报告新增了多少条翻译。 + +--- + +### Step 2: 版本号 + 更新日志 + +**2.1 计算并写入版本号** + +```shell +node .claude/skills/dootask-release/scripts/version_bump.js +``` + +脚本据 git 历史算出新 `version` 与 `codeVerson` 并写入 `package.json`,输出 JSON 含:`version`、`prevVersion`、`changelogRange`(如 `<上次release提交>..HEAD`,用于下一步圈定本次更新范围)。 + +**2.2 撰写 CHANGELOG** + +读取本次区间的提交: + +```shell +git log --stat +``` + +`--stat` 会带上每个提交的完整描述正文 + 改动文件清单;光看标题不够时用 `git show ` 看具体代码改动。 + +按 `CHANGELOG.md` 现有格式,在文件顶部 `# Changelog` 说明段之后、紧挨上一个 `## [...]` 之前,插入新版本区段: + +```markdown +## [] + +### Features + +- ... + +### Bug Fixes + +- ... + +### Performance + +- ... +``` + +撰写要求(对齐项目历史风格): +- 小节标题用**英文 Title Case**:`Features` / `Bug Fixes` / `Performance` / `Documentation` / `Security` / `Miscellaneous`,**不要译成中文**;**没有内容的小节整段省略**。 +- 条目正文用**通俗友好的简体中文**,面向**普通用户**描述更新带来的直接好处,**避免技术术语**(如 refactor、merge branch、commit lint、bump deps 等)。 +- 过滤掉对用户无意义的提交(纯构建/依赖/CI/合并提交、本技能自身的脚手架改动等)。 +- 仅凭提交标题无法判断是否对用户有价值时,结合提交的完整描述正文和实际代码改动(`git show `)再决定,不要只看一行就下结论。 +- 合并相似项;每个小节内**按用户价值与影响范围排序,重要的在前**。 + +**2.3 报告**:展示新版本号与你写的 changelog 区段,请用户过目。 + +--- ### Step 3: 构建前端 -```shell -npm run build -``` -构建前端生产版本。 -> **已知失败**:若 build 报 `public/uploads/...` 的 `EACCES: permission denied, copyfile`,是 vite 复制 `public/` 目录时碰到运行时残留的上传文件——这些文件常为 **root 属主**(容器内 root 进程写入),复制需覆盖写入,构建用户没有写权限。报错路径可能落在 `public/uploads` 下任意子目录(`tmp`、`avatar` 等),不限于 `tmp`。这不是代码问题。**补救(赋权,不删数据)**:把整个 uploads 的属主改回当前用户后重试 build: +```shell +./cmd prod +``` + +构建前端生产版本。用 `./cmd prod`,不要换成裸跑 vite(它还负责 node 检查、清 `public/js/build`、debug 切换)。 + +> **已知失败**:build 报 `public/uploads/...` 的 `EACCES: permission denied, copyfile`,是 vite 复制 `public/` 时撞到 root 属主的运行时上传文件(不限于 `tmp`,`avatar` 等都可能)。补救是赋权、不是删数据——把 uploads 属主改回当前用户后重试: > ```shell > sudo chown -R "$(id -u):$(id -g)" public/uploads > ``` -> 需 root(本机可免密 sudo;或经 docker 以 root 改权限)。**优先赋权,不要删**——`public/uploads` 含真实上传数据。即便用户要求清理,也只清临时目录 `public/uploads/tmp`,切勿删 uploads 下其他内容。 +> `public/uploads` 是真实上传数据,**不要删**;即便要清也只清 `public/uploads/tmp`。 + +--- ## 最终:提交并推送 @@ -59,8 +171,10 @@ npm run build 4. 未确认一律不执行 提交规范: -- 提交信息使用 `release: v<新版本号>`(与历史提交风格一致,参见 `git log --oneline | grep '^release:'`) -- **只 add 本次发布相关改动**,按文件名显式添加(例如 `git add package.json public/js/...`),**不要用 `git add -A` 或 `git add .`**,以免卷入未跟踪的本地实验文件 +- 提交信息使用 `release: v<新版本号>`(与历史一致,参见 `git log --oneline | grep '^release:'`) +- **只 add 本次发布相关改动**,按文件名/目录显式添加(例如 `git add package.json CHANGELOG.md language/translate.json public/language public/js`),不要用 `git add -A` / `git add .`,以免卷入未跟踪的本地实验文件 +- 不打 git tag(现行发布流程不使用 tag) +- 确认前先核对:`/tmp/dootask-release-translated.json` 等临时文件不在仓库内,工作区不应残留发布无关的未跟踪文件 ## push 之后:确认发布工作流(CI 才是真正出包) @@ -74,38 +188,17 @@ push 到 `pro` 只是触发器,真正的构建/出包由 GitHub Actions 完成 ``` - 工作流仍在跑时,挂后台轮询、结束即通知用户,**不要在前台死等**。 -### 可选:iOS 发布 +### iOS 发布(询问后决定) -`ios-publish.yml` 是**独立的手动工作流**(`workflow_dispatch`),不随 push 触发。**仅当用户明确要求**发 iOS 时执行: -```shell -gh workflow run ios-publish.yml --ref pro -R kuaifan/dootask -``` -需 `gh` 已登录且 token 含 `workflow` 权限。触发后同样可挂后台轮询其结果。 +`ios-publish.yml` 是**独立的手动工作流**(`workflow_dispatch`),不随 push 触发。Publish 成功后,用 options 或 AskUserQuestion 形式提问是否同时发布 iOS(选项:发布 iOS / 不发布): + +- 选「发布 iOS」才执行: + ```shell + gh workflow run ios-publish.yml --ref pro -R kuaifan/dootask + ``` + 需 `gh` 已登录且 token 含 `workflow` 权限;触发后可挂后台轮询结果。 +- 选「不发布」则结束。 ## 失败处理 -- 任何步骤失败立即停止,报告错误信息 -- **不要**自动重试 -- **不要**自动跳过失败步骤 -- 由用户决定如何处理 - -## 禁止项(基线测试暴露的反模式) - -| 错误做法 | 正确做法 | -|---------|---------| -| 遇到脏工作区主动提出修复方案(加 `.gitignore`、先 push 等) | **停下**,报告脏工作区事实,交用户决定 | -| 增加 `git tag v1.7.xx` 步骤 | DooTask 现行发布流程**不打 tag**,不要擅自添加 | -| `git add -A` / `git add .` | 按文件名显式添加发布相关改动 | -| 一次性 add + commit + push,不给确认机会 | 摘要 → 问确认 → 再 add/commit/push 三步分离 | -| 把 translate/version/build 顺序自作主张调整 | 顺序固定为 translate → version → build | -| 失败后"我再试一次"或"跳过这步" | 立即停止,交还给用户 | - -## Red Flags —— 出现这些念头立即停下 - -- "这个脏工作区我来帮 TA 搞定一下" → 停下,交用户 -- "顺便打个 tag 吧" → 不,没有这一步 -- "`git add -A` 省事" → 不,显式 add -- "翻译这步没改动可以跳" → 不,按顺序执行、执行后报告结果即可 -- "一起 commit + push 一气呵成" → 必须先让用户确认 -- "push 上去了,发布就完成了" → 不,push 只是触发器,要确认 GitHub Actions 的 Publish 工作流 success -- "build 报 uploads 权限错,我直接删掉" → 优先 `chown` 赋权整个 `public/uploads`(不丢数据);真要删也只删 `tmp`,别碰 uploads 下真实上传数据 +任何步骤失败立即停止、报告错误信息,交用户决定;不要自动重试或跳过。 diff --git a/.claude/skills/dootask-release/scripts/language.php b/.claude/skills/dootask-release/scripts/language.php new file mode 100644 index 000000000..38484ece1 --- /dev/null +++ b/.claude/skills/dootask-release/scripts/language.php @@ -0,0 +1,239 @@ + +// —— 把新翻译合并进 translate.json(追加 + 剔除冗余),不生成 public 文件 +// language.php generate +// —— 由 translate.json 重新生成 public/language/{web,api}/* +// +// 项目根相对脚本自身定位(脚本固定在 /.claude/skills/dootask-release/scripts/),与调用时的 cwd 无关。 +@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING); + +$ROOT = dirname(__DIR__, 4); +$LANG_DIR = $ROOT . '/language'; +$LANG_FIELDS = ['key', 'zh', 'zh-CHT', 'en', 'ko', 'ja', 'de', 'fr', 'id', 'ru']; + +if (!is_dir($LANG_DIR)) { + fwrite(STDERR, "未找到 language 目录($LANG_DIR)。\n"); + exit(1); +} +chdir($LANG_DIR); + +$cmd = $argv[1] ?? ''; + +// ---- 公共:读取 original-*.txt ---- +function read_generateds(): array +{ + $originals = []; + $generateds = []; + foreach (['web', 'api'] as $type) { + $content = file_exists("original-{$type}.txt") ? file_get_contents("original-{$type}.txt") : ""; + $array = array_values(array_filter(array_unique(explode("\n", $content)))); + $generateds[$type] = $array; + $originals = array_merge($originals, $array); + } + return [$originals, $generateds]; +} + +// ---- 公共:构建 translations 映射(normalizedKey -> obj),并收集冗余/占位符错乱 ---- +function build_translations(array $originals): array +{ + $translations = []; + $redundants = []; + $regrror = []; + if (!file_exists("translate.json")) { + fwrite(STDERR, "translate.json not exists\n"); + exit(1); + } + $tmps = json_decode(file_get_contents("translate.json"), true); + foreach ($tmps as $obj) { + if (!isset($obj['key'])) { + continue; + } + $currentKey = $obj['key']; + $originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $currentKey); + if (!in_array($originalKey, $originals)) { + $redundants[$originalKey] = $obj; + continue; + } + $translations[$originalKey] = $obj; + if (preg_match_all('/\(%[TM]\d+\)/', $currentKey, $matches)) { + foreach ($matches[0] as $match) { + foreach ($obj as $k => $v) { + if (empty($v)) { + continue; + } + if (!str_contains($v, $match)) { + $regrror[$originalKey] = ['key' => $currentKey, 'field' => $k, 'value' => $v, 'match' => $match]; + continue 2; + } + } + } + } + } + return [$translations, $redundants, $regrror]; +} + +// ---- 公共:由 translate.json + originals 重新生成 public 文件 ---- +function generate(array $generateds, array $translations): void +{ + foreach ($generateds as $type => $array) { + $datas = []; + foreach ($array as $text) { + $text = trim($text); + if (isset($translations[$text])) { + $datas[] = $translations[$text]; + } + } + $inOrder = []; + foreach ($datas as $index => $item) { + if (preg_match('/\(%[TM]\d+\)/', $item['key'])) { + $inOrder[$index] = strlen($item['key']); + } else { + $inOrder[$index] = strlen($item['key']) + 10000000000; + } + } + array_multisort($inOrder, SORT_DESC, $datas); + $results = []; + foreach ($datas as $items) { + foreach ($items as $kk => $item) { + $results[$kk][] = $item; + } + } + if ($type === 'api') { + if (!is_dir("../public/language/api")) { + mkdir("../public/language/api", 0777, true); + } + foreach ($results as $kk => $item) { + file_put_contents("../public/language/api/$kk.json", json_encode($item, JSON_UNESCAPED_UNICODE)); + } + } elseif ($type === 'web') { + if (!is_dir("../public/language/web")) { + mkdir("../public/language/web", 0777, true); + } + foreach ($results as $kk => $item) { + file_put_contents("../public/language/web/$kk.js", "if(typeof window.LANGUAGE_DATA===\"undefined\")window.LANGUAGE_DATA={};window.LANGUAGE_DATA[\"{$kk}\"]=" . json_encode($item, JSON_UNESCAPED_UNICODE)); + } + } + echo "[$type] total: " . count($results['key']) . "\n"; + } +} + +if ($cmd === 'diff') { + [$originals, $generateds] = read_generateds(); + [$translations, $redundants, $regrror] = build_translations($originals); + + // 需要翻译的数据(对齐 translate.php 150-169:占位符按单一计数器编号) + $needs = []; + foreach ($originals as $text) { + $key = trim($text); + if ($key === '') { + continue; + } + if (!isset($translations[$key])) { + $needs[$key] = $key; + } + } + $needsOut = []; + foreach ($needs as $key) { + $c = 1; + $converted = preg_replace_callback('/\((\*+)\)/', function ($m) use (&$c) { + $label = strlen($m[1]) > 1 ? "M" : "T"; + return "(%" . $label . $c++ . ")"; + }, $key); + $needsOut[] = ['key' => $converted]; + } + + echo json_encode([ + 'needsCount' => count($needsOut), + 'redundantCount' => count($redundants), + 'regexErrorCount' => count($regrror), + 'needs' => $needsOut, + 'redundants' => array_keys($redundants), + 'regexErrors' => array_values($regrror), + ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n"; + + if (count($regrror) > 0) { + exit(2); // 已有数据占位符错乱,需先修复 + } + exit(0); +} + +if ($cmd === 'apply') { + $file = $argv[2] ?? ''; + if ($file === '' || !file_exists($file)) { + fwrite(STDERR, "用法:apply (文件不存在)\n"); + exit(1); + } + [$originals, $generateds] = read_generateds(); + [$translations, $redundants, $regrror] = build_translations($originals); + if (count($regrror) > 0) { + fwrite(STDERR, "translate.json 已有条目占位符错乱,请先修复再发版。\n"); + exit(2); + } + + $incoming = json_decode(file_get_contents($file), true); + if (!is_array($incoming)) { + fwrite(STDERR, "translated.json 必须是数组\n"); + exit(1); + } + $added = 0; + foreach ($incoming as $raw) { + foreach ($GLOBALS['LANG_FIELDS'] as $f) { + if (!array_key_exists($f, $raw)) { + fwrite(STDERR, "新翻译缺字段 \"$f\":" . json_encode($raw, JSON_UNESCAPED_UNICODE) . "\n"); + exit(1); + } + } + // 占位符完整性:key 里每个 (%T1)/(%M1) 必须出现在每个非空语言值里 + if (preg_match_all('/\(%[TM]\d+\)/', $raw['key'], $m)) { + foreach ($m[0] as $match) { + foreach ($GLOBALS['LANG_FIELDS'] as $f) { + if ($f === 'key' || $f === 'zh') { + continue; + } + if (empty($raw[$f])) { + continue; + } + if (!str_contains($raw[$f], $match)) { + fwrite(STDERR, "占位符 $match 在字段 \"$f\" 缺失:{$raw['key']}\n"); + exit(1); + } + } + } + } + // 规范化:固定字段顺序 + zh 置空 + $item = []; + foreach ($GLOBALS['LANG_FIELDS'] as $f) { + $item[$f] = $f === 'zh' ? '' : $raw[$f]; + } + $originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $item['key']); + $translations[$originalKey] = $item; + $added++; + } + + // array_values:现有条目(去冗余)在前,新条目追加在后 + file_put_contents("translate.json", json_encode(array_values($translations), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + echo json_encode([ + 'added' => $added, + 'total' => count($translations), + 'droppedRedundant' => count($redundants), + ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n"; + exit(0); +} + +if ($cmd === 'generate') { + [$originals, $generateds] = read_generateds(); + [$translations] = build_translations($originals); + generate($generateds, $translations); + exit(0); +} + +fwrite(STDERR, "未知子命令:'$cmd'。可用:diff | apply | generate\n"); +exit(1); diff --git a/.claude/skills/dootask-release/scripts/version_bump.js b/.claude/skills/dootask-release/scripts/version_bump.js new file mode 100644 index 000000000..9ed8d9545 --- /dev/null +++ b/.claude/skills/dootask-release/scripts/version_bump.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// 计算并写入新版本号到 package.json(version + codeVerson),算法对齐 bin/version.js。 +// 不生成 CHANGELOG(在技能流程内撰写),只输出版本号与 changelog 的提交区间。 +// +// 项目根相对脚本自身定位(脚本固定在 /.claude/skills/dootask-release/scripts/),与调用时的 cwd 无关。 +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const ROOT = path.resolve(__dirname, '../../../..'); +const pkgFile = path.join(ROOT, 'package.json'); +const verOffset = 6394; // 版本号偏移量(与 bin/version.js 一致) +const codeOffset = 35; // 代码版本号偏移量 + +function git(cmd) { + return execSync(cmd, { cwd: ROOT, maxBuffer: 1024 * 1024 * 10 }).toString().trim(); +} + +const verCount = parseInt(git('git rev-list --count HEAD'), 10); +const codeCount = parseInt(git("git tag --merged pro -l 'v*' | wc -l"), 10); +const num = verOffset + verCount; +if (Number.isNaN(num)) { + console.error(`版本计算失败:rev-list count=${verCount}`); + process.exit(1); +} +const version = `${Math.floor(num / 10000)}.${Math.floor((num % 10000) / 100)}.${Math.floor(num % 100)}`; +const codeVersion = codeOffset + codeCount; + +let pkg = fs.readFileSync(pkgFile, 'utf8'); +const prevVersion = (pkg.match(/"version":\s*"(.*?)"/) || [])[1] || ''; +pkg = pkg.replace(/"version":\s*"(.*?)"/, `"version": "${version}"`); +pkg = pkg.replace(/"codeVerson":(.*?)(,|$)/, `"codeVerson": ${codeVersion}$2`); +fs.writeFileSync(pkgFile, pkg, 'utf8'); + +// 上一个 release 提交作为 changelog 区间下界 +let prevReleaseCommit = ''; +try { + prevReleaseCommit = git("git log --grep='^release: v' -n 1 --pretty=format:%H"); +} catch (e) { /* ignore */ } + +console.log(JSON.stringify({ + version, + codeVersion, + prevVersion, + prevReleaseCommit, + changelogRange: prevReleaseCommit ? `${prevReleaseCommit}..HEAD` : '(未找到上一个 release 提交,需人工确定区间)', +}, null, 2));