
Translating Project
- 1 installs
- Updated May 12, 2026
- lionad-morotar/translating-project
Batch-translates project docs and code via commands (scan, translate, update, glossary), syncing upstream diffs and managing a terminology glossary.
About
A command-driven project translator (scan, translate, update, glossary, config) that finds untranslated files, translates against a glossary, and syncs upstream updates, skipping git steps for non-git projects. Use it to translate or maintain translations of a project's docs and code.
- Command-based workflow: scan, translate, update, glossary, config
- Upstream-diff syncing with glossary and subagent support
Translating Project by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,980 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/lionad-morotar/translating-project --skill translating-projectAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | May 12, 2026 |
| Repository | lionad-morotar/translating-project ↗ |
What it does
Batch-translates project docs and code via commands (scan, translate, update, glossary), syncing upstream diffs and managing a terminology glossary.
Files
上下文说明
- {tp-skill}:指项目翻译助手(即本技能)
- 非 git 项目处理:如果工作目录不是 git 项目,则跳过所有 git 相关操作(如分支和提交)
- 上游:指待翻译项目的 upstream/main
- 任务清单:
{待翻译项目根目录}/.todo/tp-tasks.md - 差异清单:
{待翻译项目根目录}/.todo/tp-diff-task.md - 查找暂存区:指执行指令
git status --porcelain - 文件路径:永远使用绝对路径
执行
识别用户意图,匹配以下一项任务并执行。
| Command | Description | How to Use |
|---|---|---|
| scan | 查找项目内未翻译文件,或"更新任务清单" | 执行./commands/tp/scan.md |
| translate | 翻译项目(当用户没有明确要翻译什么时,选这个) | 执行./commands/tp/translate.md |
| update | 同步上游更新并翻译差异 | 执行./commands/tp/update.md |
| glossary | 术语表任务,如添加自定义术语 | 执行./commands/tp/glossary.md |
| config | 读取项目配置 | 执行./commands/tp/config.md |
| help | 显示帮助 | 执行./commands/tp/help.md |
资源索引
| 资源类型 | 路径 |
|---|---|
| 任务或指令 | ./commands/tp/ |
| 子代理 | ./agents/ |
| 工具函数(一般情况不会用到) | ./tools/ |
| 资源 | ./assets/ |
| 默认配置 | ./configs/ |
| 模版文件 | ./templates/ |
node_modules<role> 你是 Project Translator 的任务清单更新器。你的唯一职责是仅更新任务清单文件,将指定的文件标记为已完成。
核心原则: 1. 只读取任务清单的必要部分,避免加载完整文件 2. 只标记指定的文件,不做其他修改 3. 保持任务清单的原始格式和顺序 </role>
<upstream_input> 主代理传入:
task_list_path:任务清单文件绝对路径(必需)completed_files:已完成的文件绝对路径数组(必需)
</upstream_input>
<process>
1. 读取任务清单
使用 Read 工具读取任务清单,但可以分段读取以避免加载过大文件。
2. 更新任务清单
对于 completed_files 中的每个文件路径:
- 查找模式:使用
sed -i '' 's|\- \[ \] /path/to/file|- [x] /path/to/file|'这种精确匹配方式 - 查找该行:
- [ ] /path/to/file - 替换为:
- [x] /path/to/file
关键: 1. 使用精确匹配格式:- [ ] <文件路径> 2. 可以使用 Bash 工具执行 sed 命令:sed -i '' 's|^- \[ \] /path/to/file$|- [x] /path/to/file|' <任务清单路径> 3. 或者使用 Edit 工具进行精确的字符串替换,只修改匹配的行
3. 验证
确认所有指定的文件都已标记为 [x]。 </process>
<response_rules>
响应格式
更新完成后,仅返回确认信息:
✓ 任务清单已更新
**标记完成的文件**:
- /path/to/file1.md
- /path/to/file2.ts
- /path/to/file3.vue
**总计**:{count} 个文件已标记禁止:
- 不返回完整任务清单内容
- 不做翻译相关的解释
- 不讨论翻译内容
</response_rules>
<success_criteria>
- [ ] 所有指定文件已标记为 [x]
- [ ] 任务清单格式保持完整
- [ ] 没有意外修改其他内容
</success_criteria>
<role> 你是 Project Translator 的翻译执行器。你负责单个文件的就地翻译。
核心原则: 1. 保持代码结构和注释的对应关系 2. 根据文件内容自行选择合适的术语表 3. 保持原始文件的格式和缩进 4. 只翻译自然语言内容,不翻译代码标识符 </role>
<upstream_input> 主代理传入:
file_path:源文件路径(必需)glossary_data:完整术语表数据(由 gloss agent 加载,包含所有可用术语表及 when_to_use 说明)config:配置对象(由 loader 加载,可选)
</upstream_input>
<downstream_consumer> 翻译结果直接就地写入(同路径覆盖)。 主代理接收返回后:
- 在任务清单中标记该文件为已完成
- 继续调用 trans 翻译下一个文件
</downstream_consumer>
<process>
1. 读取源文件
cat "$FILE_PATH"2. 分析文件类型和内容
识别文件类型:
- 代码文件:.js, .ts, .py, .java 等
- 文档文件:.md, .txt 等
分析文件内容,识别涉及的技术领域
3. 选择适用的术语表
根据 glossary_data.glossaries 中的 when_to_use 说明,判断哪些术语表适用于当前文件内容:
示例判断逻辑:
- 文件内容包含 "Agent", "Skill", "Workflow" → 适用 agent 术语表
- 文件内容包含 "component", "props", "React" → 适用 frontend 术语表
- 同时包含多领域术语 → 使用多个术语表优先级规则(当术语冲突时): 1. 项目专属术语表($PROJECT_PATH/.tp/glossary/) 2. 个人共享术语表(~/.tp/glossary/) 3. 技能内置术语表(~/.claude/skills/translating-project/assets/glossary/)
4. 翻译内容
代码文件翻译
翻译内容:
- 注释(//, / /, #, ''' """ 等)
- 文档字符串(docstrings)
- 字符串字面量中的用户可见文本
不翻译内容:
- 变量名、函数名、类名
- 文件路径、URL
- 代码关键字(if, for, while 等)
- 日志标记、错误代码
文档文件翻译
翻译内容:
- 所有自然语言文本
- 标题、段落、列表
不翻译内容:
- 代码块内的代码(除非注释)
- 文件名、路径
- URL、链接文本
5. 应用术语表
1. 优先使用选中的术语表中的翻译 2. 术语表未覆盖的,根据上下文翻译 3. 保持术语在全文中一致 4. 对于 action: "keep" 的术语,保留英文原文 5. 对于 action: "translate" 的术语,强制使用指定翻译
6. 写入文件
- 保持原始格式
- 保持缩进和换行
- 就地写入(同路径覆盖原文件)
# 伪代码 - 实际使用 Write 工具
Write("$FILE_PATH", translatedContent)7. 验证
目前没有可用的验证器实现,所以跳过验证步骤。
</process>
<response_rules>
响应格式
翻译完成后,仅返回确认信息:
✓ 已完成
**文件**:`path/to/file.md`
**操作**:翻译
**字数**:{source} → {target}
**使用的术语表**:{domain1}, {domain2}
**术语表命中**:{count} 个禁止:
- 翻译过程描述
- 原文或译文摘录
- 术语解释
- 建议或额外说明
错误响应
✗ 失败
**文件**:`path/to/file.md`
**错误**:{error_message}
**建议**:{recovery_hint}常见错误:
文件不存在:检查路径无法写入:检查权限格式损坏:检查原始文件术语表数据缺失:联系主代理重新加载术语表
</response_rules>
<success_criteria>
- [ ] 文件已翻译并保存
- [ ] 原始格式和缩进保持
- [ ] 根据文件内容正确选择了适用的术语表
- [ ] 术语表已正确应用
- [ ] 文件可通过基础验证
</success_criteria>
<role> 你是 Project Translator 的 Git 同步器。你处理翻译项目与上游的同步,分析 commit 差异,协调翻译更新。 </role>
<upstream_input> 主代理传入:
project_path:项目绝对路径(必需)current_tag:可选,当前已翻译内容对应的 git 标签(如v-a1b2c3)upstream:上游分支(可选,默认upstream)upstream_branch:上游分支(可选,默认upstream/main)
</upstream_input>
<execution_flow>
<step name="check_branch" priority="first"> 确保在 translation 分支:
git branch --show-current如不在 translation 分支,切换或创建:
git checkout translation 2>/dev/null || git checkout -b translation</step>
<step name="fetch_upstream"> 获取上游更新:
git fetch origin
git fetch {upstream}</step>
<step name="get_current_tag"> 获取当前翻译对应的 commit:
git describe --tags --abbrev=0
git rev-parse --short=6 HEAD解析 tag(如 v-a1b2c3)获取 source-commit-id </step>
<step name="analyze_diff"> 分析差异:
git log {source-commit-id}..{upstream_branch} --reverse --pretty=format:"%h %s"生成差异清单 {待翻译项目根目录}/.todo/tp-diff-task.md </step>
<step name="process_commits"> 根据差异清单,批量并行处理 commits:
1. 获取所有待处理 commits 列表 2. 每批最多 3 个 commits 并行处理:
# 使用 Task 工具(`subagent_type: general-purpose`)并行启动 3 个处理代理
# Task 1 - 处理 commit 1
subagent_type: general-purpose
name: commit-processor-1
prompt: |
你是 Commit 翻译处理器。处理以下 commit:
**Commit ID**: $COMMIT_ID_1
**项目绝对路径**: $PROJECT_PATH
执行流程:
1. 获取变更统计:`git show {commit-id} --stat`
2. 获取详细变更:`git show {commit-id}`
3. 分类处理:
- 新增文件:完整翻译
- 删除文件:同步删除
- 少量修改(< 10 处 diff):对差异处增量更新
- 大量修改:重新翻译整个文件
4. 返回确认信息:处理的文件列表
# Task 2(同上,name: commit-processor-2,处理 $COMMIT_ID_2)
# Task 3(同上,name: commit-processor-3,处理 $COMMIT_ID_3)3. 等待所有子代理完成 4. 更新差异清单,对本批完成的 commits 打钩
- 查找和更新模式:使用
sed -i '' 's|\- \[ \] <commit-id>|- [x] <commit-id>|'精确匹配
5. 循环处理下一批,直到所有 commits 完成 </step>
<step name="cleanup"> 清理工作:
1. 删除差异清单 2. 提交更改:commit message 使用模版 ~/.claude/skills/translating-project/templates/commit-message.md 3. 打标签:git tag "v-$(git rev-parse --short=6 {upstream_branch})" 4. 可选:推送到 origin </step>
</execution_flow>
<output>
.todo/tp-diff-task.md- 差异清单(处理过程中生成,最后删除)
</output>
<forbidden_files> 禁止操作:
- 禁止直接操作
main或master分支(应在translation分支工作) - 禁止强制推送(
git push --force) - 禁止删除已存在的 git 标签
</forbidden_files>
<response_rules>
响应格式
同步完成后,仅返回确认信息:
✓ 同步完成
**处理 commits**:{count}
**新增文件**:{added}
**修改文件**:{modified}
**删除文件**:{deleted}
**新标签**:`v-{commit-id}`无差异时:
✓ 已是最新
**当前标签**:`v-{commit-id}`
**状态**:与 upstream 一致,无需更新禁止:
- commit 详情列表
- 文件变更描述
- Git 操作日志
错误响应
✗ 同步失败
**错误**:{error_message}
**建议**:{recovery_hint}常见错误:
分支不存在:先执行node ./tools/tp.js initupstream 未配置:添加 upstream remote冲突无法自动解决:需要手动处理
</response_rules>
<success_criteria>
- [ ] translation 分支已更新到最新 upstream
- [ ] 所有差异 commit 已处理
- [ ] 更改已提交
- [ ] 新标签已创建
</success_criteria>
# Agent Domain Glossary
# 智能体领域术语表
domain = "agent"
description = "智能体领域的术语表"
when_to_use = "当翻译内容涉及 AI Agent、智能体、Skill、技能、Workflow、工作流、LLM、大语言模型、Prompt、提示词、 Claude、OpenAI 等人工智能相关概念时使用"
[[rules]]
term = "agent"
action = "translate"
translation = "智能体"
reason = "agent 统一翻译为智能体"
[[rules]]
term = "AI Agent"
action = "translate"
translation = "AI 智能体"
reason = "AI Agent 翻译为 AI 智能体"
[[rules]]
term = "skill"
action = "keep"
translation = "skill"
reason = "在智能体领域,skill 是核心概念,保留英文术语"
[[rules]]
term = "workflow"
action = "translate"
translation = "工作流"
reason = "workflow 翻译为工作流"
[[rules]]
term = "LLM"
action = "translate"
translation = "大语言模型"
reason = "LLM 翻译为大语言模型"
[[rules]]
term = "prompt"
action = "translate"
translation = "提示词"
reason = "prompt 翻译为提示词"
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Glossary Schema",
"description": "术语表结构定义,支持 CSV 和 TOML 两种格式",
"definitions": {
"rule": {
"type": "object",
"properties": {
"term": {
"type": "string",
"description": "术语原文(必填)"
},
"action": {
"type": "string",
"enum": ["keep", "translate"],
"description": "规则类型(必填):keep 保留原文,translate 强制翻译"
},
"translation": {
"type": "string",
"description": "翻译结果(action=translate 时必填,action=keep 时可选)"
},
"reason": {
"type": "string",
"description": "翻译说明(选填)"
}
},
"required": ["term", "action"],
"if": {
"properties": {
"action": {
"const": "translate"
}
},
"required": ["action"]
},
"then": {
"required": ["translation"]
}
}
},
"allOf": [
{
"anyOf": [
{
"type": "object",
"properties": {
"format": {
"const": "csv",
"description": "CSV 格式术语表"
},
"columns": {
"type": "array",
"items": {
"type": "string",
"enum": ["term", "action", "translation", "reason"]
},
"description": "CSV 列顺序:term, action, translation, reason"
},
"requiredColumns": {
"type": "array",
"items": {
"type": "string"
},
"description": "必填列",
"default": ["term", "action", "translation"]
},
"rules": {
"type": "array",
"items": {
"$ref": "#/definitions/rule"
},
"minItems": 1,
"description": "术语规则列表"
}
},
"required": ["format", "columns", "rules"]
},
{
"type": "object",
"properties": {
"format": {
"const": "toml",
"description": "TOML 格式术语表"
},
"domain": {
"type": "string",
"description": "术语表所属领域"
},
"description": {
"type": "string",
"description": "术语表描述"
},
"rules": {
"type": "array",
"items": {
"$ref": "#/definitions/rule"
},
"minItems": 1,
"description": "术语规则列表"
}
},
"required": ["format", "domain", "rules"]
}
]
}
]
}
<objective> 加载并返回合并后的项目配置。 </objective>
<required_context>
配置系统
配置使用三层合并机制:
1. 默认配置:~/.claude/skills/translating-project/configs/setting.json 2. 个人配置:~/.tp/config.json 3. 项目配置:{project}/.tp/config.json(优先级最高)
</required_context>
<process> 识别用户意图,执行以下脚本,完成配置相关操作。
node ~/.claude/skills/translating-project/tools/tp.js config {subcommand} -w {待翻译项目根目录} [options]子命令:
get [key]- 获取配置值(支持嵌套 key,如fileFilters.ignoreGitignore)set <key> <value>- 设置配置值list- 列出配置
必填选项:
-w, --working-dir <path>- 指定工作目录(自动定位到 Git 仓库根目录)
可选选项(互斥):
-p, --project- 仅使用项目配置-g, --global- 仅使用个人配置-d, --default- 仅使用默认配置
说明:
- 不指定
-p/-g/-d时,合并三层配置(项目 > 个人 > 默认) set不能配合-d使用(默认配置只读)
示例:
# 获取合并配置
node ~/.claude/skills/translating-project/tools/tp.js config list -w {待翻译项目根目录}
# 获取个人配置
node ~/.claude/skills/translating-project/tools/tp.js config get targetLanguage -w {待翻译项目根目录} -g
# 设置项目配置
node ~/.claude/skills/translating-project/tools/tp.js config set targetLanguage "中文" -w {待翻译项目根目录}</process>
<objective> 管理项目翻译术语表。 </objective>
<required_context>
术语表系统
术语表使用三层合并机制:
1. 默认术语表:~/.claude/skills/translating-project/assets/glossary/ 2. 个人术语表:~/.tp/glossary/ 3. 项目术语表:{project}/.tp/glossary/(优先级最高)
</required_context>
<process>
node ~/.claude/skills/translating-project/tools/tp.js glossary {subcommand} -w {待翻译项目根目录} [options]子命令:
add <file>- 添加术语表文件list- 列出所有术语表show <name>- 查看术语表内容remove <name>- 删除术语表
必填选项:
-w, --working-dir <path>- 指定工作目录(自动定位到 Git 仓库根目录)
可选选项(互斥):
-p, --project- 仅使用项目术语表-g, --global- 仅使用个人术语表-d, --default- 仅使用默认术语表
说明:
- 不指定
-p/-g/-d时,显示所有层级术语表 add/remove不能配合-d使用(默认术语表只读)
示例:
# 列出所有术语表
node ~/.claude/skills/translating-project/tools/tp.js glossary list -w {待翻译项目根目录}
# 查看术语表
node ~/.claude/skills/translating-project/tools/tp.js glossary show agent -w {待翻译项目根目录}
# 添加术语表到个人
node ~/.claude/skills/translating-project/tools/tp.js glossary add ./terms.csv -w {待翻译项目根目录} -g</process>
<output>
- add:添加成功确认
- list:术语表列表
- show:术语表内容
- remove:删除成功确认
</output>
<success_criteria>
- [ ] 成功操作术语表
- [ ] 文件格式正确(CSV 或 TOML)
</success_criteria>
<objective> 扫描项目目录,识别待翻译文件,检测翻译状态,生成按优先级排序的任务清单。 </objective>
<process> 执行以下脚本,生成任务清单:
node ~/.claude/skills/translating-project/tools/tp.js scan {待翻译项目根目录}</process>
<success_criteria>
- [ ] 任务清单已生成
</success_criteria>
<objective> 翻译项目中所有待翻译文件。这是主工作流程,包含配置加载、扫描、术语表加载、循环翻译和提交。 </objective>
<process> 1. 如果项目没有任务清单,执行指令生成任务清单 2. 查找暂存区(默认暂存区的文件已翻译) 2.1 如果已翻译文件在任务清单中,但没有勾选,则勾选 2.2 如果已翻译文件不在任务清单,则忽略 3. 执行术语表指令,读取所有术语表 4. 循环,根据任务清单,批量并行翻译 4.1 从任务清单中取出3个未完成任务作为一批
node ~/.claude/skills/translating-project/tools/tp.js todo pending -w /Users/lionad/Github/LLM/pm-skills-cn | head -34.2 使用 Task 工具(subagent_type: general-purpose)并行启动 3 个翻译子代理 4.3 等待子代理结果 4.4 使用 Task 工具调用 tp-task-updater 子代理,更新任务清单,把所有已翻译的文件打钩 4.5 输出一句话:”已完成第 {N} 批翻译({本批数量} 个文件),预计还剩 {剩余任务数量} 条任务” // 可能有多个翻译代理在工作,所以剩余任务数量可能对不上,是正常的 4.6 禁止检测翻译质量,禁止等待用户确认或提交 4.7 循环,直到所有任务完成 5. 用户确认后,开始清理工作 5.1 删除任务清单 5.2 如果项目配置了 git remote origin 和 upstream,切换到 translation 分支并提交:git commit -am 'chore: cn translation' 5.3 打标以便记录当前翻译对应哪个远端提交:git tag "v-$(git rev-parse --short=6 main)" </process>
<the_subagents>
subagent_type: general-purpose
name: translator
allowed-tools: Read、Write、Bash
prompt: [reading from ../../agents/tp-translator.md]subagent_type: general-purpose
name: task-updater
allowed-tools: Read、Write、Bash
prompt: [reading from ../../agents/tp-task-updater.md]</the_subagents>
<success_criteria>
- [ ] 任务清单以删除
- [ ] 已经提交并正确打标
</success_criteria>
<objective> 获取上游更新,分析提交差异,更新翻译以保持一致。 </objective>
<process> 创建 tp-updater 代理执行此任务。 </process>
<output>
- 与上游一致的更新翻译
- 新的 git 标签(如
v-a1b2c3)
</output>
<success_criteria>
- [ ] 上游更新已获取
- [ ] 所有提交已处理
- [ ] 翻译已更新
- [ ] 新标签已创建
</success_criteria>
{
"targetLanguage": "中文",
"targetBranch": "upstream/master",
"taskTrackingFile": ".todo/project-translation-task.md",
"fileFilters": {
"ignoreGitignore": true,
"supportedExtensions": [
".md",
".py",
".js",
".ts",
".tsx",
".jsx",
".java",
".cpp",
".c",
".h",
".hpp",
".go",
".rs",
".kt",
".swift",
".rb",
".php",
".scala",
".cs",
".vue"
],
"excludeFiles": [
"LICENSE",
"COPYRIGHT",
"COPYING",
"LICENSE.md",
"LICENSE.txt",
"CHANGELOG.md",
"CONTRIBUTING.md"
],
"excludeDirs": [
".git",
".idea",
"node_modules",
"dist",
".vscode"
]
},
"translation": {
"priority": [
"README.md",
"*.md",
"docs/",
"src/",
"lib/",
"app/"
],
"previewLines": 20
}
}
{
"name": "translating-project",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "translating-project",
"version": "1.1.0",
"dependencies": {
"franc": "^6.2.0"
},
"bin": {
"tp": "tools/tp.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/collapse-white-space": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/collapse-white-space/-/collapse-white-space-2.1.0.tgz",
"integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/franc": {
"version": "6.2.0",
"resolved": "https://registry.npmmirror.com/franc/-/franc-6.2.0.tgz",
"integrity": "sha512-rcAewP7PSHvjq7Kgd7dhj82zE071kX5B4W1M4ewYMf/P+i6YsDQmj62Xz3VQm9zyUzUXwhIde/wHLGCMrM+yGg==",
"license": "MIT",
"dependencies": {
"trigram-utils": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/n-gram": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/n-gram/-/n-gram-2.0.2.tgz",
"integrity": "sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/trigram-utils": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/trigram-utils/-/trigram-utils-2.0.1.tgz",
"integrity": "sha512-nfWIXHEaB+HdyslAfMxSqWKDdmqY9I32jS7GnqpdWQnLH89r6A5sdk3fDVYqGAZ0CrT8ovAFSAo6HRiWcWNIGQ==",
"license": "MIT",
"dependencies": {
"collapse-white-space": "^2.0.0",
"n-gram": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
}
}
}
{
"name": "translating-project",
"version": "1.1.0",
"description": "项目翻译助手(tp),批量翻译项目文档和代码文件为中文",
"bin": {
"tp": "./tools/tp.js"
},
"scripts": {
"scan": "node tools/tp.js scan",
"translate": "node tools/tp.js translate",
"update": "node tools/tp.js update"
},
"engines": {
"node": ">=14.0.0"
},
"keywords": [
"translation",
"i18n",
"localization",
"claude-skill"
],
"dependencies": {
"franc": "^6.2.0"
}
}
{{type}}: {{description}}
{{#files}}
- {{.}}
{{/files}}
term,action,translation,reason hello,translate,你好 ADHD,keep,ADHD,专业名词
{{domain}} 术语表
领域:{{domain}}
描述:{{description}}
格式:TOML
[[rules]] term = "hello" action = "translate" translation = "你好"
[[rules]] term = "ADHD" action = "keep" translation = "ADHD" reason = "专业名词"
- 总进度:{{translated}}/{{total}},{{percentage}}%
- 生成时间:{{timestamp}}
const FileScanner = require('./tools/lib/scanner');
const path = require('path');
const fs = require('fs');
// 创建一个临时测试目录
const testDir = '/tmp/test_scan_exclude_' + Date.now();
fs.mkdirSync(testDir, { recursive: true });
fs.mkdirSync(path.join(testDir, 'subdir'), { recursive: true });
// 创建一些测试文件
fs.writeFileSync(path.join(testDir, 'README.md'), '# Test');
fs.writeFileSync(path.join(testDir, 'LICENSE'), 'MIT'); // 应该被排除
fs.writeFileSync(path.join(testDir, 'normal.js'), 'const x = 1;');
fs.writeFileSync(path.join(testDir, 'subdir', 'nested.md'), '# Nested');
console.log('测试目录结构:');
console.log(fs.readdirSync(testDir, { recursive: true }));
// 配置:排除 LICENSE
const config = {
fileFilters: {
supportedExtensions: ['.md', '.js'],
excludeFiles: ['LICENSE', 'COPYRIGHT'], // 应该排除 LICENSE
excludeDirs: ['node_modules', '.git']
},
translation: {
langdetectMinLength: 3
}
};
console.log('\n配置 excludeFiles:', config.fileFilters.excludeFiles);
async function test() {
const scanner = new FileScanner(config);
const files = await scanner.scan(testDir);
console.log('\n扫描结果:');
files.forEach(f => console.log(' -', path.basename(f.path)));
// 验证 LICENSE 是否被排除
const hasLicense = files.some(f => path.basename(f.path) === 'LICENSE');
console.log('\n验证:');
console.log('LICENSE 是否被排除:', !hasLicense);
if (hasLicense) {
console.log('✗ BUG: LICENSE 应该被排除但却被扫描了');
} else {
console.log('✓ 正确: LICENSE 已被排除');
}
// 清理
fs.rmSync(testDir, { recursive: true });
}
test().catch(console.error);
#!/usr/bin/env node
/**
* config 命令 - 配置管理
*
* 用法: tp config <subcommand> [options]
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { getGitRoot } = require('./lib/git');
const SUBCOMMANDS = {
get,
set,
list,
};
function parseArgs(args) {
const options = {
subcommand: null,
args: [],
workingDir: process.cwd(),
source: null, // 'project' | 'global' | 'default' | null
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--help' || arg === '-h') {
showHelp();
process.exit(0);
} else if (arg === '--working-dir' || arg === '-w') {
options.workingDir = args[++i];
} else if (arg === '--global' || arg === '-g') {
if (options.source) throw new Error('选项 -p/-g/-d 互斥,只能使用其一');
options.source = 'global';
} else if (arg === '--project' || arg === '-p') {
if (options.source) throw new Error('选项 -p/-g/-d 互斥,只能使用其一');
options.source = 'project';
} else if (arg === '--default' || arg === '-d') {
if (options.source) throw new Error('选项 -p/-g/-d 互斥,只能使用其一');
options.source = 'default';
} else if (!options.subcommand && !arg.startsWith('-')) {
options.subcommand = arg;
} else {
options.args.push(arg);
}
}
return options;
}
function showHelp() {
console.log(`
配置管理
用法: tp config <subcommand> -w <path> [options]
子命令:
get [key] 获取配置值
set <key> <value> 设置配置值
list 列出配置
必填选项:
-w, --working-dir <path> 指定工作目录(将自动定位到 Git 仓库根目录)
可选选项(互斥,只能使用其一):
-p, --project 仅使用项目配置
-g, --global 仅使用个人全局配置
-d, --default 仅使用默认配置
说明:
- -w 指定工作目录后,会自动使用 git rev-parse --show-toplevel 获取仓库根目录
- 不指定 -p/-g/-d 时,按优先级合并:项目 > 个人 > 默认
- 指定 -p/-g/-d 时,仅使用对应层级的配置
- set 子命令不能配合 -d 使用(默认配置只读)
示例:
tp config list -w ./my-project
tp config get targetLanguage -w ./my-project -g
tp config set targetLanguage "中文" -w ./my-project
`);
}
function getConfigPath(source, workingDir) {
switch (source) {
case 'default':
return path.join(__dirname, '..', 'configs', 'setting.json');
case 'global':
return path.join(os.homedir(), '.tp', 'config.json');
case 'project':
default:
return path.join(workingDir, '.tp', 'config.json');
}
}
function readConfig(configPath) {
if (!fs.existsSync(configPath)) {
return {};
}
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
}
function writeConfig(configPath, config) {
const dir = path.dirname(configPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
}
function loadConfig(source, workingDir) {
// 如果指定了 source,只读取对应层级
if (source) {
const configPath = getConfigPath(source, workingDir);
return {
...readConfig(configPath),
_meta: { source }
};
}
// 否则合并三层配置
const defaultConfig = readConfig(getConfigPath('default', workingDir));
const globalConfig = readConfig(getConfigPath('global', workingDir));
const projectConfig = readConfig(getConfigPath('project', workingDir));
return {
...defaultConfig,
...globalConfig,
...projectConfig,
_meta: {
project: Object.keys(projectConfig).length > 0,
global: Object.keys(globalConfig).length > 0,
default: Object.keys(defaultConfig).length > 0,
}
};
}
async function get(args, options) {
const key = args[0];
const config = loadConfig(options.source, options.workingDir);
if (key) {
const value = key.split('.').reduce((obj, k) => obj?.[k], config);
console.log(value !== undefined ? value : '(未设置)');
} else {
const { _meta, ...rest } = config;
console.log(JSON.stringify(rest, null, 2));
}
}
async function set(args, options) {
const [key, ...valueParts] = args;
const value = valueParts.join(' ');
if (!key || value === undefined) {
throw new Error('请指定 key 和 value');
}
if (options.source === 'default') {
throw new Error('不能修改默认配置,请使用 -p 或 -g');
}
// 默认写入项目配置,-g 时写入个人配置
const targetSource = options.source === 'global' ? 'global' : 'project';
const configPath = getConfigPath(targetSource, options.workingDir);
const config = readConfig(configPath);
// 支持嵌套 key
const keys = key.split('.');
let current = config;
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) current[keys[i]] = {};
current = current[keys[i]];
}
// 尝试解析为 JSON
try {
current[keys[keys.length - 1]] = JSON.parse(value);
} catch {
current[keys[keys.length - 1]] = value;
}
writeConfig(configPath, config);
const configType = targetSource === 'global' ? '个人配置' : '项目配置';
console.log(`${configType} 设置: ${key} = ${value}`);
}
async function list(args, options) {
const config = loadConfig(options.source, options.workingDir);
const { _meta, ...rest } = config;
if (_meta.source) {
console.log(`${_meta.source} 配置:`);
} else {
console.log('合并配置(项目 → 个人 → 默认):');
}
console.log(JSON.stringify(rest, null, 2));
if (!_meta.source) {
console.log('\n配置来源:');
console.log(` 项目配置: ${_meta.project ? '✓' : '✗'}`);
console.log(` 个人配置: ${_meta.global ? '✓' : '✗'}`);
console.log(` 默认配置: ${_meta.default ? '✓' : '✗'}`);
}
}
async function command(args) {
const options = parseArgs(args);
if (!options.subcommand) {
console.error('错误: 请指定子命令');
showHelp();
process.exit(1);
}
// 自动定位到 Git 仓库根目录
const gitRoot = getGitRoot(options.workingDir);
if (gitRoot) {
options.workingDir = gitRoot;
}
const handler = SUBCOMMANDS[options.subcommand];
if (!handler) {
console.error(`错误: 未知子命令 "${options.subcommand}"`);
showHelp();
process.exit(1);
}
return await handler(options.args, options);
}
module.exports = command;
if (require.main === module) {
command(process.argv.slice(2)).catch(error => {
console.error(`错误: ${error.message}`);
process.exit(1);
});
}
#!/usr/bin/env node
/**
* glossary 命令 - 术语表管理
*
* 用法: tp glossary <subcommand> -w <path> [options]
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { getGitRoot } = require('./lib/git');
const SUBCOMMANDS = {
add,
list,
show,
remove,
};
function parseArgs(args) {
const options = {
subcommand: null,
args: [],
workingDir: process.cwd(),
source: null, // 'project' | 'global' | 'default' | null
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--help' || arg === '-h') {
showHelp();
process.exit(0);
} else if (arg === '--working-dir' || arg === '-w') {
options.workingDir = args[++i];
} else if (arg === '--global' || arg === '-g') {
if (options.source) throw new Error('选项 -p/-g/-d 互斥,只能使用其一');
options.source = 'global';
} else if (arg === '--project' || arg === '-p') {
if (options.source) throw new Error('选项 -p/-g/-d 互斥,只能使用其一');
options.source = 'project';
} else if (arg === '--default' || arg === '-d') {
if (options.source) throw new Error('选项 -p/-g/-d 互斥,只能使用其一');
options.source = 'default';
} else if (!options.subcommand && !arg.startsWith('-')) {
options.subcommand = arg;
} else {
options.args.push(arg);
}
}
return options;
}
function showHelp() {
console.log(`
术语表管理
用法: tp glossary <subcommand> -w <path> [options]
子命令:
add <file> 添加术语表文件
list 列出所有术语表
show <name> 查看术语表内容
remove <name> 删除术语表
必填选项:
-w, --working-dir <path> 指定工作目录(将自动定位到 Git 仓库根目录)
可选选项(互斥,只能使用其一):
-p, --project 仅使用项目术语表
-g, --global 仅使用个人全局术语表
-d, --default 仅使用默认术语表
说明:
- -w 指定工作目录后,会自动使用 git rev-parse --show-toplevel 获取仓库根目录
- 不指定 -p/-g/-d 时,显示所有层级术语表
- 指定 -p/-g/-d 时,仅操作对应层级的术语表
- add/remove 不能配合 -d 使用(默认术语表只读)
示例:
tp glossary list -w ./my-project
tp glossary show agent -w ./my-project
tp glossary add ./terms.csv -w ./my-project -g
`);
}
function getGlossaryDir(source, workingDir) {
switch (source) {
case 'default':
return path.join(__dirname, '..', 'assets', 'glossary');
case 'global':
return path.join(os.homedir(), '.tp', 'glossary');
case 'project':
default:
return path.join(workingDir, '.tp', 'glossary');
}
}
function ensureGlossaryDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function getGlossaryFiles(dir) {
if (!fs.existsSync(dir)) {
return [];
}
return fs.readdirSync(dir)
.filter(f => f.endsWith('.csv') || f.endsWith('.toml'))
.sort();
}
/**
* 加载合并后的术语表(三层合并)
*/
function loadMergedGlossaries(source, workingDir) {
const defaultDir = path.join(__dirname, '..', 'assets', 'glossary');
const globalDir = path.join(os.homedir(), '.tp', 'glossary');
const projectDir = path.join(workingDir, '.tp', 'glossary');
const result = {
default: [],
global: [],
project: [],
merged: {},
_meta: {
default: fs.existsSync(defaultDir),
global: fs.existsSync(globalDir),
project: fs.existsSync(projectDir),
}
};
// 按优先级从低到高加载
if (fs.existsSync(defaultDir)) {
result.default = getGlossaryFiles(defaultDir).map(f => ({
name: f.replace(/\.(csv|toml)$/i, ''),
file: f,
path: path.join(defaultDir, f),
source: 'default'
}));
}
if (fs.existsSync(globalDir)) {
result.global = getGlossaryFiles(globalDir).map(f => ({
name: f.replace(/\.(csv|toml)$/i, ''),
file: f,
path: path.join(globalDir, f),
source: 'global'
}));
}
if (fs.existsSync(projectDir)) {
result.project = getGlossaryFiles(projectDir).map(f => ({
name: f.replace(/\.(csv|toml)$/i, ''),
file: f,
path: path.join(projectDir, f),
source: 'project'
}));
}
// 构建合并索引(高优先级覆盖低优先级同名文件)
[...result.default, ...result.global, ...result.project].forEach(g => {
result.merged[g.name] = g;
});
return result;
}
/**
* 添加术语表
*/
async function add(args, options) {
const filePath = args[0];
if (!filePath) {
throw new Error('请指定术语表文件路径');
}
if (!fs.existsSync(filePath)) {
throw new Error(`文件不存在: ${filePath}`);
}
const ext = path.extname(filePath).toLowerCase();
if (!['.csv', '.toml'].includes(ext)) {
throw new Error('只支持 CSV 或 TOML 格式');
}
if (options.source === 'default') {
throw new Error('不能修改默认术语表,请使用 -p 或 -g');
}
// 默认写入项目术语表,-g 时写入个人术语表
const targetSource = options.source === 'global' ? 'global' : 'project';
const glossaryDir = getGlossaryDir(targetSource, options.workingDir);
ensureGlossaryDir(glossaryDir);
const fileName = path.basename(filePath);
const destPath = path.join(glossaryDir, fileName);
fs.copyFileSync(filePath, destPath);
const sourceType = targetSource === 'global' ? '个人术语表' : '项目术语表';
console.log(`添加术语表到 ${sourceType}: ${destPath}`);
}
/**
* 列出术语表
*/
async function list(args, options) {
const glossaries = loadMergedGlossaries(options.source, options.workingDir);
// 如果指定了 source,仅显示对应层级
if (options.source) {
console.log(`${options.source} 术语表:`);
const files = glossaries[options.source];
if (files.length > 0) {
files.forEach(g => console.log(` - ${g.file}`));
} else {
console.log(' (无)');
}
return;
}
// 显示所有层级
console.log('默认术语表:');
if (glossaries.default.length > 0) {
glossaries.default.forEach(g => console.log(` - ${g.file}`));
} else {
console.log(' (无)');
}
console.log('\n个人术语表:');
if (glossaries.global.length > 0) {
glossaries.global.forEach(g => console.log(` - ${g.file}`));
} else {
console.log(' (无)');
}
console.log('\n项目术语表:');
if (glossaries.project.length > 0) {
glossaries.project.forEach(g => console.log(` - ${g.file}`));
} else {
console.log(' (无)');
}
console.log('\n实际生效的术语表(按优先级合并):');
const mergedNames = Object.keys(glossaries.merged);
if (mergedNames.length > 0) {
mergedNames.forEach(name => {
const g = glossaries.merged[name];
console.log(` - ${name} (${g.source})`);
});
} else {
console.log(' (无)');
}
}
/**
* 查看术语表
*/
async function show(args, options) {
const name = args[0];
if (!name) {
throw new Error('请指定术语表名称');
}
// 如果指定了 source,直接从对应层级查找
if (options.source) {
const glossaryDir = getGlossaryDir(options.source, options.workingDir);
const exts = ['.toml', '.csv'];
let filePath = null;
for (const ext of exts) {
const tryPath = path.join(glossaryDir, name + ext);
if (fs.existsSync(tryPath)) {
filePath = tryPath;
break;
}
}
if (!filePath) {
throw new Error(`${options.source} 术语表中不存在: ${name}`);
}
console.log(`来源: ${options.source} (${filePath})\n`);
console.log(fs.readFileSync(filePath, 'utf8'));
return;
}
// 按优先级查找
const glossaries = loadMergedGlossaries(options.source, options.workingDir);
const glossary = glossaries.merged[name];
if (!glossary) {
throw new Error(`术语表不存在: ${name}`);
}
console.log(`来源: ${glossary.source} (${glossary.path})\n`);
console.log(fs.readFileSync(glossary.path, 'utf8'));
}
/**
* 删除术语表
*/
async function remove(args, options) {
const name = args[0];
if (!name) {
throw new Error('请指定术语表名称');
}
if (options.source === 'default') {
throw new Error('不能删除默认术语表,请使用 -p 或 -g');
}
// 默认删除项目术语表,-g 时删除个人术语表
const targetSource = options.source === 'global' ? 'global' : 'project';
const glossaryDir = getGlossaryDir(targetSource, options.workingDir);
const exts = ['.toml', '.csv'];
let filePath = null;
for (const ext of exts) {
const tryPath = path.join(glossaryDir, name + ext);
if (fs.existsSync(tryPath)) {
filePath = tryPath;
break;
}
}
if (!filePath) {
const sourceType = targetSource === 'global' ? '个人术语表' : '项目术语表';
throw new Error(`${sourceType}中不存在: ${name}`);
}
fs.unlinkSync(filePath);
const sourceType = targetSource === 'global' ? '个人术语表' : '项目术语表';
console.log(`从 ${sourceType} 删除: ${filePath}`);
}
async function command(args) {
const options = parseArgs(args);
if (!options.subcommand) {
console.error('错误: 请指定子命令');
showHelp();
process.exit(1);
}
// 自动定位到 Git 仓库根目录
const gitRoot = getGitRoot(options.workingDir);
if (gitRoot) {
options.workingDir = gitRoot;
}
const handler = SUBCOMMANDS[options.subcommand];
if (!handler) {
console.error(`错误: 未知子命令 "${options.subcommand}"`);
showHelp();
process.exit(1);
}
return await handler(options.args, options);
}
module.exports = command;
if (require.main === module) {
command(process.argv.slice(2)).catch(error => {
console.error(`错误: ${error.message}`);
process.exit(1);
});
}
const fs = require('fs');
const path = require('path');
const os = require('os');
/**
* 获取默认配置
* @returns {Object} 默认配置对象
*/
function getDefaultConfig() {
return {
targetLanguage: "中文",
taskTrackingFile: ".todo/tp-tasks.md",
fileFilters: {
ignoreGitignore: true,
supportedExtensions: [
".md", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".cpp", ".c",
".h", ".hpp", ".go", ".rs", ".kt", ".swift", ".rb", ".php",
".scala", ".cs", ".vue"
],
excludeFiles: ["LICENSE", "COPYRIGHT", "COPYING"],
excludeDirs: [
"__pycache__", ".git", ".idea", "node_modules", "venv", "env",
"dist", "build", "target", ".vscode"
]
},
translation: {
priority: ["README.md", "CONTRIBUTING.md", "CHANGELOG.md", "docs/", "src/", "lib/", "app/"],
previewLines: 20
}
};
}
/**
* 加载配置(支持多层级)
* 优先级:项目配置 > 个人配置 > 默认配置
*
* @param {string} projectPath - 项目路径
* @returns {Object} 配置对象
*/
function loadConfig(projectPath) {
const defaultConfig = getDefaultConfig();
// 1. 加载个人配置 (~/.tp/config.json)
const personalConfigPath = path.join(os.homedir(), '.tp', 'config.json');
let personalConfig = {};
if (fs.existsSync(personalConfigPath)) {
try {
const content = fs.readFileSync(personalConfigPath, 'utf-8');
personalConfig = JSON.parse(content);
} catch (error) {
console.warn(`警告: 无法加载个人配置文件 ${personalConfigPath}: ${error.message}`);
}
}
// 2. 加载项目配置 (<project>/.tp/config.json)
let projectConfig = {};
if (projectPath) {
const projectConfigPath = path.join(projectPath, '.tp', 'config.json');
if (fs.existsSync(projectConfigPath)) {
try {
const content = fs.readFileSync(projectConfigPath, 'utf-8');
projectConfig = JSON.parse(content);
projectConfig._source = 'project';
} catch (error) {
console.warn(`警告: 无法加载项目配置文件 ${projectConfigPath}: ${error.message}`);
}
}
}
// 3. 合并配置(项目 > 个人 > 默认)
const mergedConfig = deepMerge(defaultConfig, personalConfig, projectConfig);
// 记录配置来源
if (!mergedConfig._source) {
if (projectConfig._source) {
mergedConfig._source = 'project';
} else if (Object.keys(personalConfig).length > 0) {
mergedConfig._source = 'personal';
} else {
mergedConfig._source = 'default';
}
}
return mergedConfig;
}
/**
* 深度合并多个对象
* 对象递归合并,数组合并去重
*/
function deepMerge(...objects) {
const result = {};
for (const obj of objects) {
for (const key in obj) {
if (key.startsWith('_')) {
// 保留内部属性
result[key] = obj[key];
} else if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
// 递归合并对象
result[key] = deepMerge(result[key] || {}, obj[key]);
} else if (Array.isArray(obj[key])) {
// 数组合并(去重)
const existing = Array.isArray(result[key]) ? result[key] : [];
result[key] = [...new Set([...existing, ...obj[key]])];
} else {
// 基本类型直接覆盖
result[key] = obj[key];
}
}
}
return result;
}
module.exports = {
loadConfig,
getDefaultConfig,
deepMerge
};
const fs = require('fs');
const path = require('path');
const DEFAULT_INNER_DICT_PATH = path.join(__dirname, 'inner.json');
const DEFAULT_TEMP_TXT_PATH = path.join(__dirname, 'temp.txt');
function loadDictTrie(dictPath) {
const filePath = dictPath || DEFAULT_INNER_DICT_PATH;
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw || '{}');
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch (error) {
return {};
}
}
function saveDictTrie(dictPath, trie) {
const filePath = dictPath || DEFAULT_INNER_DICT_PATH;
const safeTrie = trie && typeof trie === 'object' && !Array.isArray(trie) ? trie : {};
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const tmpPath = `${filePath}.tmp.${process.pid}`;
fs.writeFileSync(tmpPath, `${JSON.stringify(safeTrie, null, 2)}\n`, 'utf8');
fs.renameSync(tmpPath, filePath);
}
function normalizeWordForDict(word) {
return String(word || '').trim().toLowerCase();
}
function extractEnglishWordsBySpaces(text) {
const value = String(text || '');
const map = new Map();
const re = /(?:^|\s)([A-Za-z][A-Za-z0-9.+-]*)(?=\s|$)/g;
for (const match of value.matchAll(re)) {
const original = match?.[1];
if (!original) continue;
const normalized = normalizeWordForDict(original);
if (!normalized) continue;
if (normalized.length >= 3) {
if (!map.has(normalized)) {
map.set(normalized, original);
}
}
}
return Array.from(map, ([normalized, original]) => ({ normalized, original }));
}
function insertWordIntoTrie(trie, normalizedWord, originalWord) {
const word = String(normalizedWord || '').trim().toLowerCase();
if (!word) return false;
let node = trie;
for (const ch of word) {
const next = node[ch];
if (!next || typeof next !== 'object' || Array.isArray(next)) node[ch] = {};
node = node[ch];
}
if (typeof node.$ === 'string' && node.$) return false;
node.$ = String(originalWord || normalizedWord);
return true;
}
function trieToWordList(trie) {
const results = [];
const walk = (node) => {
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
if (typeof node.$ === 'string' && node.$) results.push(node.$);
for (const [key, child] of Object.entries(node)) {
if (key === '$') continue;
walk(child);
}
};
walk(trie);
return Array.from(new Set(results)).sort((a, b) => a.localeCompare(b));
}
function readInnerDictionary(options) {
const trie = loadDictTrie(options?.dictPath);
return trieToWordList(trie);
}
function extractAndWriteInnerDictionary(text, options) {
const words = extractEnglishWordsBySpaces(text);
if (words.length === 0) return { words: [], added: [] };
const trie = loadDictTrie(options?.dictPath);
const added = [];
for (const { normalized, original } of words) {
const ok = insertWordIntoTrie(trie, normalized, original);
if (ok) added.push(original);
}
if (added.length > 0) saveDictTrie(options?.dictPath, trie);
return { words: words.map(w => w.original), added };
}
function extractAndWriteInnerDictionaryFromTempTxt(options) {
const tempTxtPath = options?.tempTxtPath || DEFAULT_TEMP_TXT_PATH;
let text = '';
try {
text = fs.readFileSync(tempTxtPath, 'utf8');
} catch (error) {
return { words: [], added: [], tempTxtPath };
}
if (!text || !text.trim()) return { words: [], added: [], tempTxtPath };
const result = extractAndWriteInnerDictionary(text, { dictPath: options?.dictPath });
return { ...result, tempTxtPath };
}
module.exports = {
readInnerDictionary,
extractAndWriteInnerDictionary,
extractAndWriteInnerDictionaryFromTempTxt,
};
const fs = require('fs');
const path = require('path');
/**
* 创建符号链接
* @param {string} sourcePath - 源路径
* @param {string} targetPath - 目标路径
*/
function createSymlink(sourcePath, targetPath) {
try {
try {
const stats = fs.lstatSync(targetPath);
if (stats.isSymbolicLink() || stats.isFile()) {
fs.unlinkSync(targetPath);
}
} catch (e) {
}
fs.symlinkSync(sourcePath, targetPath);
console.log(`已创建符号链接: ${targetPath} -> ${sourcePath}`);
} catch (error) {
throw new Error(`创建符号链接失败: ${error.message}`);
}
}
module.exports = {
createSymlink
};
const path = require('path');
/**
* 判断文件是否应被排除
* @param {string} filename - 文件名
* @param {Object} config - 配置对象
* @returns {boolean} 是否应排除
*/
function shouldExcludeFile(filename, config) {
const excludeFiles = config?.fileFilters?.excludeFiles || [];
return excludeFiles.includes(filename);
}
function shouldExcludeDir(dirname, config) {
const excludeDirs = config?.fileFilters?.excludeDirs || [];
return excludeDirs.includes(dirname);
}
/**
* 判断文件类型是否支持
* @param {string} filename - 文件名
* @param {Object} config - 配置对象
* @returns {boolean} 是否支持
*/
function isSupportedFile(filename, config) {
const supportedExtensions = config?.fileFilters?.supportedExtensions || ['.md', '.js', '.py'];
const ext = path.extname(filename).toLowerCase();
return supportedExtensions.includes(ext);
}
module.exports = {
shouldExcludeFile,
shouldExcludeDir,
isSupportedFile
};
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const { isSupportedFile } = require('./filter');
/**
* 检查文件是否被修改
* @param {string} projectPath - 项目路径
* @param {string} filePath - 文件路径
* @param {string} targetBranch - 目标分支
* @returns {boolean} 是否被修改
*/
function checkFileModified(projectPath, filePath, targetBranch = 'upstream/master') {
try {
const relativePath = path.relative(projectPath, filePath);
const diffOutput = execSync(
`git diff ${targetBranch} -- "${relativePath}"`,
{ cwd: projectPath, encoding: 'utf-8' }
);
return diffOutput.trim().length > 0;
} catch (error) {
return false;
}
}
/**
* 检测已修改的文件(与目标分支对比)
* @param {string} projectPath - 项目路径
* @param {string} targetBranch - 目标分支
* @returns {Array} 已修改的文件列表
*/
function detectModifiedFiles(projectPath, targetBranch = 'upstream/master') {
try {
const { loadConfig } = require('./config');
const config = loadConfig(projectPath);
const diffOutput = execSync(
`git diff --name-only ${targetBranch}`,
{ cwd: projectPath, encoding: 'utf-8' }
);
const changedFiles = diffOutput.trim().split('\n').filter(Boolean);
const modifiedFiles = [];
for (const file of changedFiles) {
const filePath = path.join(projectPath, file);
const filename = path.basename(file);
if (fs.existsSync(filePath) && isSupportedFile(filename, config)) {
modifiedFiles.push(filePath);
}
}
return modifiedFiles;
} catch (error) {
console.warn(`检测修改文件失败: ${error.message}`);
return [];
}
}
/**
* 恢复文件到上游版本
* @param {string} projectPath - 项目路径
* @param {string} filePath - 文件路径
*/
function restoreUpstreamVersion(projectPath, filePath, targetBranch = 'upstream/master') {
try {
const relativePath = path.relative(projectPath, filePath);
const command = `git checkout ${targetBranch} -- "${relativePath}"`;
execSync(command, { cwd: projectPath, stdio: 'inherit' });
console.log(`已恢复上游版本: ${filePath}`);
} catch (error) {
throw new Error(`恢复上游版本失败: ${error.message}`);
}
}
/**
* 获取 Git 仓库根目录
* @param {string} cwd - 起始目录(可选,默认当前工作目录)
* @returns {string|null} Git 仓库根目录,如果不是 git 仓库则返回 null
*/
function getGitRoot(cwd = process.cwd()) {
try {
const root = execSync('git rev-parse --show-toplevel', {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'] // 忽略错误输出
}).trim();
return root;
} catch (error) {
return null;
}
}
module.exports = {
checkFileModified,
detectModifiedFiles,
restoreUpstreamVersion,
getGitRoot
};
const fs = require('fs');
const path = require('path');
function readGitignore(projectPath) {
const gitignorePath = path.join(projectPath, '.gitignore');
if (!fs.existsSync(gitignorePath)) {
return [];
}
try {
const content = fs.readFileSync(gitignorePath, 'utf-8');
const lines = content.split('\n').map(line => line.trim());
return lines.filter(line => line && !line.startsWith('#'));
} catch (error) {
console.warn(`警告: 无法读取 .gitignore 文件: ${error.message}`);
return [];
}
}
/**
* 判断文件是否被 .gitignore 忽略
* @param {string} filePath - 文件路径
* @param {string} projectPath - 项目路径
* @param {Array} gitignorePatterns - 忽略规则列表
* @returns {boolean} 是否被忽略
*/
function isIgnoredByGitignore(filePath, projectPath, gitignorePatterns) {
const relativePath = path.relative(projectPath, filePath);
for (const pattern of gitignorePatterns) {
if (relativePath.startsWith(pattern) || relativePath.includes(pattern)) {
return true;
}
if (pattern.endsWith('/')) {
const dirPattern = pattern.slice(0, -1);
const relativeDir = path.dirname(relativePath);
if (relativeDir === dirPattern || relativeDir.startsWith(dirPattern + '/')) {
return true;
}
}
if (pattern.includes('*')) {
const regexPattern = pattern
.replace(/\*/g, '.*')
.replace(/\?/g, '.');
const regex = new RegExp(regexPattern);
if (regex.test(relativePath) || regex.test(path.basename(relativePath))) {
return true;
}
}
}
return false;
}
module.exports = {
readGitignore,
isIgnoredByGitignore
};
const config = require('./config');
const gitignore = require('./gitignore');
const filter = require('./filter');
const todo = require('./todo');
const git = require('./git');
const valid = require('./valid');
const file = require('./file');
const parse = require('./parse');
const lang = require('./lang');
const scanner = require('./scanner');
const priority = require('./priority');
module.exports = {
...config,
...gitignore,
...filter,
...todo,
...git,
...valid,
...file,
...parse,
...lang,
scanner,
...priority,
};
const fs = require('fs');
const path = require('path');
const { readInnerDictionary } = require('./dict');
let francPromise;
function normalizeLanguageTag(language) {
if (!language) return '';
const value = String(language).trim().toLowerCase();
if (!value) return '';
const normalized = value.replace(/_/g, '-');
const base = normalized.split('-')[0];
return base;
}
function normalizeTargetLanguage(targetLanguage) {
if (!targetLanguage) return '';
const value = String(targetLanguage).trim().toLowerCase();
if (!value) return '';
if (value === '中文' || value === '汉语' || value === '简体中文' || value === '繁體中文' || value === '繁体中文') {
return 'zh';
}
if (value === '英文' || value === '英语' || value === 'english') return 'en';
if (value === '日文' || value === '日语' || value === 'japanese') return 'ja';
if (value === '韩文' || value === '韩语' || value === 'korean') return 'ko';
if (value === '俄文' || value === '俄语' || value === 'russian') return 'ru';
const base = normalizeLanguageTag(value);
if (base === 'zh' || base === 'en' || base === 'ja' || base === 'ko' || base === 'ru') return base;
return base || value;
}
function loadFranc() {
if (!francPromise) {
francPromise = import('franc').then(m => ({ franc: m.franc, francAll: m.francAll }));
}
return francPromise;
}
function getTargetFrancCodes(targetLanguage) {
const target = normalizeTargetLanguage(targetLanguage);
if (target === 'zh') return new Set(['cmn', 'zho', 'yue', 'wuu']);
if (target === 'en') return new Set(['eng']);
if (target === 'ja') return new Set(['jpn']);
if (target === 'ko') return new Set(['kor']);
if (target === 'ru') return new Set(['rus']);
return new Set();
}
function getDefaultFrancOnlyList() {
return [
'cmn',
'zho',
'yue',
'wuu',
'eng',
'jpn',
'kor',
'rus'
];
}
function getFrancOnlyListFromConfig(config) {
const defaultOnly = getDefaultFrancOnlyList();
const defaultOnlySet = new Set(defaultOnly);
const configOnly = Array.isArray(config?.translation?.langdetectOnly) ? config.translation.langdetectOnly : [];
const filtered = configOnly
.map(item => String(item || '').trim().toLowerCase())
.filter(item => defaultOnlySet.has(item));
return filtered.length > 0 ? filtered : defaultOnly;
}
function isMarkdownFile(filePath) {
const ext = path.extname(String(filePath || '')).toLowerCase();
return ext === '.md' || ext === '.mdx' || ext === '.markdown';
}
function stripMarkdownTables(markdown) {
const lines = String(markdown || '').split(/\r?\n/);
const output = [];
const isSeparatorLine = (line) => /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line);
const looksLikeTableRow = (line) => {
const pipeCount = (String(line).match(/\|/g) || []).length;
return pipeCount >= 2;
};
let inTable = false;
for (const line of lines) {
if (isSeparatorLine(line)) {
if (output.length > 0 && looksLikeTableRow(output[output.length - 1])) output.pop();
inTable = true;
continue;
}
if (inTable) {
if (!line.trim()) continue;
if (looksLikeTableRow(line)) continue;
inTable = false;
}
output.push(line);
}
return output.join('\n');
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function stripDictionaryWords(text, config) {
const dict = readInnerDictionary({ dictPath: config?.translation?.dictPath });
if (!Array.isArray(dict) || dict.length === 0) return text;
const tokenChars = 'A-Za-z0-9.+-';
const words = dict
.map(w => String(w || '').trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length);
let value = String(text || '');
for (const word of words) {
const escaped = escapeRegExp(word);
const re = new RegExp(`(^|[^${tokenChars}])(${escaped})(?=$|[^${tokenChars}])`, 'gi');
value = value.replace(re, '$1');
}
return value;
}
function cleanMarkdownForLangdetect(markdown, config) {
let value = String(markdown || '');
value = value.replace(/^\uFEFF/, '');
value = value.replace(/^---[\s\S]*?\n---\s*/m, '');
value = value.replace(/```\w+\r?\n[\s\S]*?\r?\n```/g, ' ');
value = value.replace(/~~~[\s\S]*?~~~/g, ' ');
value = value.replace(/\*\*[\s\S]*?\*\*/g, ' ');
value = value.replace(/<!--[\s\S]*?-->/g, ' ');
value = value.replace(/!\[[^\]]*]\([^)]*\)/g, ' ');
value = value.replace(/\[([^\]]+)]\([^)]*\)/g, '$1');
value = value.replace(/^\[[^\]]+]:\s*\S+.*$/gm, ' ');
value = value.replace(/`[^`]*`/g, ' ');
value = value.replace(/<[^>]+>/g, ' ');
value = value.replace(/^#{1,6}\s+/gm, '');
value = value.replace(/^\s*>\s+/gm, '');
value = value.replace(/^\s*[-*+]\s+/gm, '');
value = value.replace(/^\s*\d+\.\s+/gm, '');
// "1.2、1.1.2"
value = value.replace(/\d+(?:\.\d)+\.?/g, ' ');
// url
value = value.replace(/https?:\/\/\S+/g, ' ');
// table
value = stripMarkdownTables(value);
value = stripDictionaryWords(value, config);
// "[] task"
value = value.replace(/\[[xX\s]\]/g, '');
// collapse multiple space
value = value.replace(/\s+/g, ' ').trim();
return value;
}
async function detectLanguageFromText(text, config) {
if (!text) return 'unknown';
const value = String(text);
if (!value.trim()) return 'unknown';
const { francAll } = await loadFranc();
const minLengthFromConfig = Number(config?.translation?.langdetectMinLength);
const minLength = Number.isFinite(minLengthFromConfig) && minLengthFromConfig > 0 ? minLengthFromConfig : 3;
const minScoreFromConfig = Number(config?.translation?.langdetectMinScore);
const minScore = Number.isFinite(minScoreFromConfig) && minScoreFromConfig >= 0 && minScoreFromConfig <= 1 ? minScoreFromConfig : 0.7;
const only = getFrancOnlyListFromConfig(config);
const results = francAll(value, { minLength, only });
if (!Array.isArray(results) || results.length === 0) return 'unknown';
const [topLang, topScore] = results[0];
if (!topLang || topLang === 'und') return 'unknown';
if (typeof topScore === 'number' && topScore < minScore) return 'unknown';
return String(topLang).trim().toLowerCase();
}
function readFileHead(filePath, maxBytes) {
const bytes = Number.isFinite(maxBytes) && maxBytes > 0 ? Math.floor(maxBytes) : 65536;
const fd = fs.openSync(filePath, 'r');
try {
const buffer = Buffer.alloc(bytes);
const read = fs.readSync(fd, buffer, 0, bytes, 0);
return buffer.subarray(0, read).toString('utf8');
} finally {
fs.closeSync(fd);
}
}
function detectLanguageFromFile(filePath, config) {
const maxBytesFromConfig = Number(config?.translation?.langdetectMaxBytes);
const sample = readFileHead(filePath, Number.isFinite(maxBytesFromConfig) && maxBytesFromConfig > 0 ? maxBytesFromConfig : 65536);
const cleaned = isMarkdownFile(filePath) ? cleanMarkdownForLangdetect(sample, config) : sample;
// console.log('[info] cleaned', cleaned)
return detectLanguageFromText(cleaned, config);
}
function isTranslatableByLanguage(filePath, config) {
return isFileTranslated(filePath, config).then(translated => !translated);
}
async function isFileTranslated(filePath, config) {
const target = normalizeTargetLanguage(config?.targetLanguage || '中文');
if (!target) return false;
const maxBytesFromConfig = Number(config?.translation?.langdetectMaxBytes);
const maxBytes = Number.isFinite(maxBytesFromConfig) && maxBytesFromConfig > 0 ? maxBytesFromConfig : 65536;
let sample = '';
try {
sample = readFileHead(filePath, maxBytes);
} catch (error) {
return false;
}
if (isMarkdownFile(filePath)) {
sample = cleanMarkdownForLangdetect(sample, config);
}
const allowed = getTargetFrancCodes(target);
if (allowed.size === 0) return false;
const { francAll } = await loadFranc();
const minLengthFromConfig = Number(config?.translation?.langdetectMinLength);
const minLength = Number.isFinite(minLengthFromConfig) && minLengthFromConfig > 0 ? minLengthFromConfig : 3;
const only = getFrancOnlyListFromConfig(config);
const results = francAll(sample, { minLength, only });
if (!Array.isArray(results) || results.length === 0) return false;
// console.log('[info] results', results)
const topNFromConfig = Number(config?.translation?.langdetectTopN);
const topN = Number.isFinite(topNFromConfig) && topNFromConfig > 0 ? Math.floor(topNFromConfig) : 3;
const minScoreFromConfig = Number(config?.translation?.langdetectTopMinScore);
const minScore = Number.isFinite(minScoreFromConfig) && minScoreFromConfig >= 0 && minScoreFromConfig <= 1 ? minScoreFromConfig : 0.9;
const topResults = results.slice(0, topN);
for (const [lang, score] of topResults) {
const normalizedLang = String(lang || '').trim().toLowerCase();
if (!allowed.has(normalizedLang)) continue;
if (typeof score === 'number' && score >= minScore) return true;
}
return false;
}
module.exports = {
normalizeLanguageTag,
normalizeTargetLanguage,
detectLanguageFromText,
detectLanguageFromFile,
isFileTranslated,
isTranslatableByLanguage
};
/**
* 解析命令行参数
* @param {Array} args - 参数数组
* @returns {Object} 参数对象
*/
function parseArgs(args) {
const params = {};
for (let i = 0; i < args.length; i += 2) {
const key = args[i].replace(/^--/, '');
const value = args[i + 1];
params[key] = value;
}
return params;
}
module.exports = {
parseArgs
};
const path = require('path');
/**
* 检查字符串是否匹配 glob 模式
* @param {string} str - 要检查的字符串
* @param {string} pattern - glob 模式(如 "*.md", "docs/**")
* @returns {boolean}
*/
function matchGlob(str, pattern) {
// 将 glob 模式转为正则表达式
let regexPattern = pattern
.replace(/\*\*/g, '{{GLOBSTAR}}') // 临时替换 **
.replace(/\*/g, '[^/]*') // * 匹配任意非斜杠字符
.replace(/\?/g, '.') // ? 匹配单个字符
.replace(/\{\{GLOBSTAR\}\}/g, '.*'); // ** 匹配任意字符包括斜杠
// 处理目录结尾的斜杠(如 "docs/" 应该匹配 "docs" 目录下的内容)
if (pattern.endsWith('/')) {
regexPattern = regexPattern + '.*';
}
const regex = new RegExp(`^${regexPattern}$`, 'i');
return regex.test(str);
}
/**
* 计算文件的优先级分数
* @param {string} filePath - 文件路径
* @param {Array<string>} priorityPatterns - 优先级模式数组
* @returns {number} - 分数(越高越优先)
*/
function calculatePriorityScore(filePath, priorityPatterns = []) {
if (!Array.isArray(priorityPatterns) || priorityPatterns.length === 0) {
return 0;
}
const fileName = path.basename(filePath);
const normalizedPath = filePath.replace(/\\/g, '/');
let score = 0;
for (let i = 0; i < priorityPatterns.length; i++) {
const pattern = priorityPatterns[i];
const patternPriority = priorityPatterns.length - i; // 越靠前的模式优先级越高
// 检查文件名是否匹配
if (matchGlob(fileName, pattern)) {
score += patternPriority * 100;
}
// 检查相对路径是否匹配
if (matchGlob(normalizedPath, pattern)) {
score += patternPriority * 100;
}
// 检查路径中是否包含该目录模式(如 "docs/" 匹配 "docs/guide.md")
if (pattern.endsWith('/')) {
const dirPattern = pattern.slice(0, -1);
if (normalizedPath.includes('/' + dirPattern + '/') || normalizedPath.startsWith(dirPattern + '/')) {
score += patternPriority * 50;
}
}
}
return score;
}
/**
* 根据优先级配置排序文件列表
* @param {Array} files - 文件列表(对象数组,每个对象包含 path 属性)
* @param {Array<string>} priorityPatterns - 优先级模式数组
* @returns {Array} - 排序后的文件列表
*/
function sortByPriority(files, priorityPatterns = []) {
if (!Array.isArray(files) || files.length === 0) {
return files;
}
// 如果没有优先级配置,按字母顺序排序
if (!Array.isArray(priorityPatterns) || priorityPatterns.length === 0) {
return [...files].sort((a, b) => {
const pathA = a.path || a;
const pathB = b.path || b;
return pathA.localeCompare(pathB);
});
}
return [...files].sort((a, b) => {
const pathA = a.path || a;
const pathB = b.path || b;
const scoreA = calculatePriorityScore(pathA, priorityPatterns);
const scoreB = calculatePriorityScore(pathB, priorityPatterns);
// 分数高的排在前面
if (scoreA !== scoreB) {
return scoreB - scoreA;
}
// 分数相同则按字母顺序
return pathA.localeCompare(pathB);
});
}
module.exports = {
matchGlob,
calculatePriorityScore,
sortByPriority
};
#!/usr/bin/env node
/**
* 文件扫描器 - 模块化扫描功能
*/
const fs = require('fs');
const path = require('path');
const { readGitignore, isIgnoredByGitignore } = require('./gitignore');
const { shouldExcludeFile, shouldExcludeDir, isSupportedFile } = require('./filter');
const { isFileTranslated } = require('./lang');
const { sortByPriority } = require('./priority');
class FileScanner {
constructor(config = {}) {
this.config = config;
}
/**
* 扫描项目目录
*/
async scan(projectPath) {
projectPath = path.resolve(projectPath);
const files = [];
const ignoreGitignore = this.config?.fileFilters?.ignoreGitignore || false;
const gitignorePatterns = ignoreGitignore ? readGitignore(projectPath) : [];
const taskTrackingFile = this.config?.taskTrackingFile || '.todo/tp-tasks.md';
const taskTrackingFilePath = path.isAbsolute(taskTrackingFile)
? path.resolve(taskTrackingFile)
: path.resolve(projectPath, taskTrackingFile);
const scanDir = async (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (shouldExcludeDir(entry.name, this.config)) continue;
await scanDir(fullPath);
} else if (entry.isFile()) {
if (path.resolve(fullPath) === taskTrackingFilePath) continue;
if (shouldExcludeFile(entry.name, this.config)) continue;
// 检查 .gitignore
if (ignoreGitignore && isIgnoredByGitignore(fullPath, projectPath, gitignorePatterns)) {
continue;
}
if (isSupportedFile(entry.name, this.config)) {
const translated = await isFileTranslated(fullPath, this.config);
files.push({
path: fullPath,
translated,
relativePath: path.relative(projectPath, fullPath),
});
}
}
}
};
await scanDir(projectPath);
// 根据配置的优先级排序
const priorityPatterns = this.config?.translation?.priority;
return sortByPriority(files, priorityPatterns);
}
/**
* 检查单个文件的翻译状态
*/
async checkFile(filePath) {
if (!fs.existsSync(filePath)) {
throw new Error(`文件不存在: ${filePath}`);
}
const translated = await isFileTranslated(filePath, this.config);
return {
path: filePath,
translated,
};
}
}
module.exports = FileScanner;
/**
* 转义正则表达式特殊字符
* @param {string} string - 原始字符串
* @returns {string} 转义后的字符串
*/
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
module.exports = {
escapeRegExp
};
const fs = require('fs');
const path = require('path');
const { escapeRegExp } = require('./str');
const { sortByPriority } = require('./priority');
/**
* 查找剩余任务(使用 sed 模式)
* @param {string} todoPath - 任务清单文件路径
* @returns {Array} 剩余待翻译的文件路径列表
*/
function findPendingTasks(todoPath) {
if (!fs.existsSync(todoPath)) {
throw new Error(`任务清单不存在: ${todoPath}`);
}
const content = fs.readFileSync(todoPath, 'utf-8');
const lines = content.split('\n');
const pendingFiles = [];
for (const line of lines) {
const match = line.match(/^- \[ \] (.+)$/);
if (match) {
pendingFiles.push(match[1]);
}
}
return pendingFiles;
}
/**
* 生成 sed 命令来标记文件为已完成
* @param {string} todoPath - 任务清单文件路径
* @param {string} filePath - 文件路径
* @returns {string} sed 命令
*/
function generateSedCompleteCommand(todoPath, filePath) {
const escapedPath = filePath.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
return `sed -i '' 's|^- \\[ \\] ${escapedPath}$|- [x] ${filePath}|' "${todoPath}"`;
}
/**
* 生成 sed 命令来查找剩余任务(用于 grep/sed)
* @param {string} todoPath - 任务清单文件路径
* @returns {string} sed 命令
*/
function generateSedFindPendingCommand(todoPath) {
return `sed -n 's/^- \\[ \\] \\(.*\\)$/\\1/p' "${todoPath}"`;
}
/**
* 确保存在任务清单文件
*/
function ensureTodoFileExists(todoPath) {
todoPath = path.resolve(todoPath);
// 确保目录存在
const todoDir = path.dirname(todoPath);
if (!fs.existsSync(todoDir)) {
fs.mkdirSync(todoDir, { recursive: true });
}
// 确保文件存在
if (!fs.existsSync(todoPath)) {
fs.writeFileSync(todoPath, '', 'utf-8');
}
}
/**
* 写入任务清单文件
* @param {Array} files - 文件列表(string 或 { path, translated })
* @param {string} outputPath - 输出路径
* @param {Object} config - 配置对象(可选,用于优先级排序)
*/
function writeTodoFile(files, outputPath, config = {}) {
outputPath = path.resolve(outputPath);
// 确保目录存在
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
let items = (Array.isArray(files) ? files : [])
.map(file => {
if (typeof file === 'string') return { path: file, translated: false };
if (file && typeof file === 'object') {
const filePath = file.path || file.filePath;
if (typeof filePath === 'string' && filePath) return { path: filePath, translated: Boolean(file.translated) };
}
return null;
})
.filter(Boolean);
// 根据配置的优先级排序
const priorityPatterns = config?.translation?.priority;
items = sortByPriority(items, priorityPatterns);
const translatedCount = items.filter(i => i.translated).length;
const toTranslateCount = items.length - translatedCount;
let content = '# 项目翻译任务清单\n\n';
items.forEach(item => {
content += `- [${item.translated ? 'x' : ' '}] ${item.path}\n`;
});
fs.writeFileSync(outputPath, content, 'utf-8');
console.log(`已生成待翻译清单: ${outputPath}`);
console.log(`项目内涉及 ${items.length} 个文件,其中 ${translatedCount} 个文件已翻译,剩余 ${toTranslateCount} 个文件需要翻译`);
}
/**
* 读取任务清单
* @param {string} todoPath - 任务清单文件路径
* @returns {Array} 已完成的文件列表
*/
function readTodoList(todoPath) {
if (!fs.existsSync(todoPath)) {
throw new Error(`任务清单不存在: ${todoPath}`);
}
const content = fs.readFileSync(todoPath, 'utf-8');
const lines = content.split('\n');
const completedFiles = [];
for (const line of lines) {
const match = line.match(/^- \[x\] (.+)$/);
if (match) {
completedFiles.push(match[1]);
}
}
return completedFiles;
}
/**
* 更新任务状态
* @param {string} todoPath - 任务清单文件路径
* @param {string} filePath - 文件路径
* @param {string} status - 状态
*/
function updateTodoStatus(todoPath, filePath, status) {
if (!fs.existsSync(todoPath)) {
throw new Error(`任务清单不存在: ${todoPath}`);
}
const content = fs.readFileSync(todoPath, 'utf-8');
const lines = content.split('\n');
const pattern = new RegExp(`^- \\[(x| )\\] ${escapeRegExp(filePath)}$`);
let updated = false;
const newLines = lines.map(line => {
if (pattern.test(line)) {
updated = true;
if (status === 'completed') {
return `- [x] ${filePath}`;
} else {
return `- [ ] ${filePath}`;
}
}
return line;
});
if (!updated) {
throw new Error(`在任务清单中未找到文件: ${filePath}`);
}
fs.writeFileSync(todoPath, newLines.join('\n'), 'utf-8');
console.log(`已更新任务清单: ${filePath} -> ${status}`);
}
module.exports = {
ensureTodoFileExists,
writeTodoFile,
readTodoList,
updateTodoStatus,
findPendingTasks,
generateSedCompleteCommand,
generateSedFindPendingCommand
};
const fs = require('fs');
const path = require('path');
/**
* 验证 CSV 文件格式
* @param {string} filePath - 文件路径
* @returns {boolean} 是否验证通过
* @throws {Error} 验证失败时抛出详细错误信息
*/
function validateCSV(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.trim().split('\n');
if (lines.length < 2) {
throw new Error(`CSV 文件 ${filePath} 格式错误:文件至少需要包含表头和一行数据`);
}
const header = lines[0];
const requiredColumns = ['term', 'action', 'translation'];
const columns = header.split(',').map(col => col.trim());
for (const requiredCol of requiredColumns) {
if (!columns.includes(requiredCol)) {
throw new Error(`CSV 文件 ${filePath} 表头错误:缺少必需的列 "${requiredCol}",当前列为 ${columns.join(', ')}`);
}
}
if (!columns.includes('reason')) {
console.warn(`提示:CSV 文件 ${filePath} 表头建议包含 "reason" 列用于记录翻译说明`);
}
const expectedColumnOrder = ['term', 'action', 'translation', 'reason'];
const columnOrderMatches = expectedColumnOrder.every((col, index) =>
!columns[index] || columns[index] === col
);
if (!columnOrderMatches) {
console.warn(`提示:CSV 文件 ${filePath} 建议使用列顺序:${expectedColumnOrder.join(', ')}`);
}
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const parts = line.split(',');
if (parts.length < 3) {
throw new Error(`CSV 文件 ${filePath} 第 ${i + 1} 行格式错误:至少需要 3 列数据,当前只有 ${parts.length} 列`);
}
const term = parts[0].trim();
const action = parts[1].trim();
const translation = parts[2] ? parts[2].trim() : '';
if (!term) {
throw new Error(`CSV 文件 ${filePath} 第 ${i + 1} 行错误:"term" 字段不能为空`);
}
if (!action) {
throw new Error(`CSV 文件 ${filePath} 第 ${i + 1} 行错误:"action" 字段不能为空`);
}
if (!['keep', 'translate'].includes(action)) {
throw new Error(`CSV 文件 ${filePath} 第 ${i + 1} 行错误:"action" 字段值必须是 "keep" 或 "translate",当前值为 "${action}"`);
}
if (action === 'translate' && !translation) {
throw new Error(`CSV 文件 ${filePath} 第 ${i + 1} 行错误:当 action 为 "translate" 时,"translation" 字段不能为空`);
}
}
return true;
}
/**
* 验证 TOML 文件格式
* @param {string} filePath - 文件路径
* @returns {boolean} 是否验证通过
* @throws {Error} 验证失败时抛出详细错误信息
*/
function validateTOML(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
if (!content.includes('domain = ')) {
throw new Error(`TOML 文件 ${filePath} 格式错误:缺少必需的 "domain" 字段`);
}
if (!content.includes('[[rules]]')) {
throw new Error(`TOML 文件 ${filePath} 格式错误:缺少必需的 [[rules]] 数组定义`);
}
const ruleMatches = content.matchAll(/\[\[rules\]\][\s\S]*?(?=\[\[rules\]\]|$)/g);
let ruleIndex = 0;
for (const match of ruleMatches) {
const ruleText = match[0];
const trimmedRuleText = ruleText.replace(/\[\[rules\]\]/, '').trim();
if (!trimmedRuleText || trimmedRuleText.length === 0) {
continue;
}
ruleIndex++;
const termMatch = ruleText.match(/term\s*=\s*"([^"]+)"/);
const actionMatch = ruleText.match(/action\s*=\s*"([^"]+)"/);
const translationMatch = ruleText.match(/translation\s*=\s*"([^"]+)"/);
const reasonMatch = ruleText.match(/reason\s*=\s*"([^"]+)"/);
if (!termMatch) {
throw new Error(`TOML 文件 ${filePath} 第 ${ruleIndex} 条规则错误:缺少必需的 "term" 字段`);
}
if (!actionMatch) {
throw new Error(`TOML 文件 ${filePath} 第 ${ruleIndex} 条规则错误:缺少必需的 "action" 字段`);
}
const action = actionMatch[1];
if (!['keep', 'translate'].includes(action)) {
throw new Error(`TOML 文件 ${filePath} 第 ${ruleIndex} 条规则错误:"action" 字段值必须是 "keep" 或 "translate",当前值为 "${action}"`);
}
if (action === 'translate') {
if (!translationMatch) {
throw new Error(`TOML 文件 ${filePath} 第 ${ruleIndex} 条规则错误:当 action 为 "translate" 时,"translation" 字段不能为空`);
}
if (!translationMatch[1]) {
throw new Error(`TOML 文件 ${filePath} 第 ${ruleIndex} 条规则错误:"translation" 字段不能为空字符串`);
}
}
if (!reasonMatch) {
console.warn(`提示:TOML 文件 ${filePath} 第 ${ruleIndex} 条规则建议添加 "reason" 字段用于记录翻译说明`);
}
}
if (ruleIndex === 0) {
throw new Error(`TOML 文件 ${filePath} 格式错误:[[rules]] 数组中没有任何规则定义`);
}
return true;
}
/**
* 验证术语表文件
* @param {string} filePath - 文件路径
* @returns {boolean} 是否验证通过
* @throws {Error} 验证失败时抛出详细错误信息
*/
function validateGlossaryFile(filePath) {
if (!fs.existsSync(filePath)) {
throw new Error(`术语表文件不存在:${filePath}`);
}
const ext = path.extname(filePath).toLowerCase();
if (ext === '.csv') {
return validateCSV(filePath);
} else if (ext === '.toml') {
return validateTOML(filePath);
} else {
throw new Error(`术语表文件格式不支持:${filePath},仅支持 .csv 和 .toml 格式`);
}
}
module.exports = {
validateCSV,
validateTOML,
validateGlossaryFile
};
#!/usr/bin/env node
/**
* scan 命令 - 扫描项目生成任务清单
*
* 用法: tp scan <path> [options]
*/
const fs = require('fs');
const path = require('path');
const { loadConfig, writeTodoFile } = require('./lib');
const scanner = require('./lib/scanner');
/**
* 解析命令行参数
*/
function parseArgs(args) {
const options = {
path: null,
format: 'markdown', // markdown | json
output: null, // 自定义输出路径
verbose: false,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--help' || arg === '-h') {
showHelp();
process.exit(0);
} else if (arg === '--format' || arg === '-f') {
options.format = args[++i];
} else if (arg === '--output' || arg === '-o') {
options.output = args[++i];
} else if (arg === '--verbose' || arg === '-v') {
options.verbose = true;
} else if (!arg.startsWith('-') && !options.path) {
options.path = arg;
}
}
return options;
}
/**
* 显示帮助信息
*/
function showHelp() {
console.log(`
扫描项目,生成待翻译文件清单
用法: tp scan <path> [options]
参数:
<path> 项目路径
选项:
-f, --format <type> 输出格式: markdown (默认) | json
-o, --output <path> 自定义输出路径
-v, --verbose 显示详细信息
-h, --help 显示帮助
示例:
tp scan ./my-project
tp scan ./my-project --format=json
tp scan ./my-project --output=./custom-tasks.md
`);
}
/**
* 执行扫描
*/
async function scan(options) {
const projectPath = path.resolve(options.path);
// 检查路径是否存在
if (!fs.existsSync(projectPath)) {
throw new Error(`项目路径不存在: ${projectPath}`);
}
// 加载配置
const config = loadConfig(projectPath);
if (options.verbose) {
console.log(`配置来源: ${config._source || 'default'}`);
}
// 创建扫描器
const fileScanner = new scanner(config);
// 执行扫描
if (options.verbose) {
console.log(`正在扫描: ${projectPath}`);
}
const files = await fileScanner.scan(projectPath);
// 统计
const stats = {
total: files.length,
translated: files.filter(f => f.translated).length,
pending: files.filter(f => !f.translated).length,
};
stats.percentage = stats.total > 0
? Math.round((stats.translated / stats.total) * 100)
: 0;
// 确定输出路径
const outputPath = options.output || path.join(
projectPath,
config.taskTrackingFile || '.todo/tp-tasks.md'
);
// 输出结果
if (options.format === 'json') {
console.log(JSON.stringify({ stats, files }, null, 2));
} else {
// 生成任务清单
writeTodoFile(files, outputPath, config);
console.log(`
扫描完成:
总文件数: ${stats.total}
已翻译: ${stats.translated}
待翻译: ${stats.pending}
翻译率: ${stats.percentage}%
任务清单: ${outputPath}
`);
}
return { stats, files, outputPath };
}
/**
* 命令入口
*/
async function command(args) {
const options = parseArgs(args);
if (!options.path) {
console.error('错误: 请指定项目路径');
showHelp();
process.exit(1);
}
return await scan(options);
}
/**
* 扫描项目(供程序调用)
*/
async function scanProject(projectPath, config) {
const resolvedPath = path.resolve(projectPath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`项目路径不存在: ${resolvedPath}`);
}
const fileScanner = new scanner(config);
const files = await fileScanner.scan(resolvedPath);
return files.map(f => ({
path: f.path,
translated: f.translated
}));
}
module.exports = { command, scan, scanProject };
module.exports.default = command;
// 直接运行
if (require.main === module) {
command(process.argv.slice(2)).catch(error => {
console.error(`错误: ${error.message}`);
process.exit(1);
});
}
#!/usr/bin/env node
/**
* todo 命令 - 任务清单管理
*
* 用法: tp todo <subcommand> [options]
*/
const fs = require('fs');
const path = require('path');
const { findPendingTasks, generateSedFindPendingCommand } = require('./lib/todo');
const SUBCOMMANDS = {
list,
pending,
'sed-pending': sedPending,
update,
clear,
};
function parseArgs(args) {
const options = {
subcommand: null,
args: [],
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--help' || arg === '-h') {
showHelp();
process.exit(0);
} else if (!options.subcommand && !arg.startsWith('-')) {
options.subcommand = arg;
} else {
options.args.push(arg);
}
}
return options;
}
function showHelp() {
console.log(`
任务清单管理
用法: tp todo <subcommand> [options]
子命令:
list 列出任务清单
pending 列出剩余待翻译任务
sed-pending 使用 sed 模式列出剩余任务
update <file> 更新任务状态
clear 清空任务清单
示例:
tp todo list
tp todo pending
tp todo sed-pending
tp todo update README.md --status=done
tp todo clear
`);
}
function getTodoPath() {
return path.join(process.cwd(), '.todo', 'tp-tasks.md');
}
async function list() {
const todoPath = getTodoPath();
if (!fs.existsSync(todoPath)) {
console.log('任务清单不存在');
return;
}
const content = fs.readFileSync(todoPath, 'utf8');
console.log(content);
}
async function pending() {
const todoPath = getTodoPath();
if (!fs.existsSync(todoPath)) {
console.log('任务清单不存在');
return;
}
const pendingFiles = findPendingTasks(todoPath);
if (pendingFiles.length === 0) {
console.log('没有待翻译的任务');
return;
}
console.log(`剩余 ${pendingFiles.length} 个待翻译任务:`);
pendingFiles.forEach((file, index) => {
console.log(`${index + 1}. ${file}`);
});
}
async function sedPending() {
const todoPath = getTodoPath();
if (!fs.existsSync(todoPath)) {
console.log('任务清单不存在');
return;
}
const sedCommand = generateSedFindPendingCommand(todoPath);
console.log('使用 sed 模式查找剩余任务:');
console.log(`命令: ${sedCommand}`);
console.log();
// 执行 sed 命令
const { execSync } = require('child_process');
try {
const result = execSync(sedCommand, { encoding: 'utf8' });
if (result.trim()) {
console.log('剩余待翻译任务:');
console.log(result);
} else {
console.log('没有待翻译的任务');
}
} catch (error) {
console.log('执行 sed 命令时出错:', error.message);
}
}
async function update(args) {
const [filePath, ...rest] = args;
if (!filePath) {
throw new Error('请指定文件路径');
}
// 解析 --status 参数
const statusIndex = rest.indexOf('--status');
const status = statusIndex !== -1 ? rest[statusIndex + 1] : 'done';
const todoPath = getTodoPath();
if (!fs.existsSync(todoPath)) {
throw new Error('任务清单不存在');
}
let content = fs.readFileSync(todoPath, 'utf8');
// 简单的任务状态更新:将 - [ ] 改为 - [x]
const escapedPath = filePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(- \\[ \\]) (.*${escapedPath}.*)`, 'g');
content = content.replace(regex, `- [x] $2`);
fs.writeFileSync(todoPath, content);
console.log(`更新任务: ${filePath} -> ${status}`);
}
async function clear() {
const todoPath = getTodoPath();
if (fs.existsSync(todoPath)) {
fs.unlinkSync(todoPath);
console.log('已清空任务清单');
} else {
console.log('任务清单不存在');
}
}
async function command(args) {
const options = parseArgs(args);
if (!options.subcommand) {
console.error('错误: 请指定子命令');
showHelp();
process.exit(1);
}
const handler = SUBCOMMANDS[options.subcommand];
if (!handler) {
console.error(`错误: 未知子命令 "${options.subcommand}"`);
showHelp();
process.exit(1);
}
return await handler(options.args);
}
module.exports = command;
if (require.main === module) {
command(process.argv.slice(2)).catch(error => {
console.error(`错误: ${error.message}`);
process.exit(1);
});
}
#!/usr/bin/env node
/**
* Project Translator CLI
* 统一命令行入口
*
* 模块化设计:CLI 负责参数解析和命令分发,
* 实际功能由 commands/ 目录下的独立模块实现
*/
const path = require('path');
// 命令模块映射
const commands = {
scan: () => require('./scan'),
glossary: () => require('./glossary'),
todo: () => require('./todo'),
config: () => require('./config'),
};
async function main() {
const args = process.argv.slice(2);
const command = args[0];
if (!command || command === '--help' || command === '-h') {
showHelp();
return;
}
if (command === '--version' || command === '-v') {
showVersion();
return;
}
const commandLoader = commands[command];
if (!commandLoader) {
console.error(`错误: 未知命令 "${command}"`);
console.error(`运行 "tp --help" 查看可用命令`);
process.exit(1);
}
try {
const loaded = commandLoader();
// 支持默认导出和命名导出
const handler = typeof loaded === 'function' ? loaded : (loaded.command || loaded.default || loaded);
if (typeof handler !== 'function') {
throw new Error(`命令模块未导出有效的处理函数`);
}
await handler(args.slice(1));
} catch (error) {
console.error(`错误: ${error.message}`);
if (process.env.DEBUG) {
console.error(error.stack);
}
process.exit(1);
}
}
function showHelp() {
console.log(`
Project Translator (tp) - 项目翻译助手
用法: tp <command> [options]
命令:
scan <path> 扫描项目,生成任务清单
glossary <subcmd> 术语表管理
config <subcmd> 配置管理
todo <subcmd> 任务清单管理
选项:
-h, --help 显示帮助
-v, --version 显示版本
--debug 显示调试信息
示例:
tp scan ./my-project
tp glossary add ./terms.csv
详细信息:
参阅 SKILL.md
`);
}
function showVersion() {
const pkg = require('../package.json');
console.log(`tp version ${pkg.version}`);
}
main();