proj2md-py 2.2.0__py3-none-any.whl
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.
proj2md.py
ADDED
|
@@ -0,0 +1,1247 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
proj2md.py —— 项目源码一键拼接工具(输出 Markdown,专为投喂网页端 AI 设计)
|
|
5
|
+
把散落在各个子目录里的代码 / 配置 / 文档文件,合并成一份结构清晰的 Markdown 文档:
|
|
6
|
+
标题层级 + 目录树 + 文件索引表 + 语法高亮代码块,并附带「给 AI 的阅读说明」,
|
|
7
|
+
方便直接粘贴给 ChatGPT / Claude / Gemini / DeepSeek / 通义 / 文心等网页端 AI。
|
|
8
|
+
忽略规则(按顺序生效,任一命中即跳过):
|
|
9
|
+
1. 隐藏目录:所有以 . 开头的文件夹默认整目录忽略(--include-hidden 可关闭)
|
|
10
|
+
2. 目录名单:DEFAULT_EXCLUDE_DIRS 黑名单 + --exclude-dir 追加
|
|
11
|
+
3. 文件名单:DEFAULT_EXCLUDE_FILES(锁文件等) + --exclude-file 追加
|
|
12
|
+
4. 通配符 :DEFAULT_EXCLUDE_PATTERNS(*.min.js/*.png 等) + --exclude-pattern 追加
|
|
13
|
+
5. 扩展名白名单:不在 DEFAULT_EXTS 中的扩展名跳过(--ext / --only-ext / --any-text 调整)
|
|
14
|
+
※ --include-pattern 拥有最高优先级:即使命中上述任何忽略规则也会强制包含,
|
|
15
|
+
且能「穿透」隐藏目录忽略(如 --include-pattern ".github/*")
|
|
16
|
+
多语言界面(v2.2.0 新增):
|
|
17
|
+
语言解析优先级:--lang 参数 > 配置文件 language 字段 > 系统自动探测 > 英文兜底
|
|
18
|
+
- 默认 auto:自动跟随系统语言(中文系统 → 中文输出,其余 → 英文输出)
|
|
19
|
+
- --lang zh / --lang en:临时切换界面语言(含 --help、控制台报告、生成的文档说明)
|
|
20
|
+
- proj2md.json 中 "language": "zh" / "en" / "auto":持久化设置
|
|
21
|
+
快速上手
|
|
22
|
+
python proj2md.py # 拼接当前目录 -> project_bundle.md
|
|
23
|
+
python proj2md.py /path/to/project # 拼接指定项目
|
|
24
|
+
python proj2md.py --clip # 生成并复制到剪贴板
|
|
25
|
+
python proj2md.py --prompt "帮我找出潜在 bug"
|
|
26
|
+
python proj2md.py --dry-run # 只预览,不写文件
|
|
27
|
+
python proj2md.py --init-config # 生成配置文件模板
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
import argparse
|
|
31
|
+
import fnmatch
|
|
32
|
+
import json
|
|
33
|
+
import locale
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import shutil
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
import warnings
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from datetime import datetime
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
VERSION = "2.2.0"
|
|
44
|
+
TOOL = "proj2md"
|
|
45
|
+
CONFIG_FILENAME = "proj2md.json"
|
|
46
|
+
DEFAULT_OUTPUT = "project_bundle.md"
|
|
47
|
+
MARK = "\x00" # 行号回填内部标记(输出前必定整体移除)
|
|
48
|
+
MARK_RE = re.compile("\x00(\\d+)\x00")
|
|
49
|
+
# ─────────────────────────── 默认规则 ───────────────────────────
|
|
50
|
+
DEFAULT_EXTS = {
|
|
51
|
+
# 编程语言
|
|
52
|
+
"py", "pyw", "js", "mjs", "cjs", "ts", "jsx", "tsx",
|
|
53
|
+
"java", "c", "h", "cpp", "cc", "hpp", "cs", "go", "rs", "rb", "php",
|
|
54
|
+
"swift", "kt", "kts", "scala", "dart", "m", "mm", "pl", "pm", "lua",
|
|
55
|
+
"r", "jl", "hs", "clj", "ex", "exs", "erl", "groovy", "asm", "zig", "nim", "v",
|
|
56
|
+
# Web / 模板
|
|
57
|
+
"html", "htm", "css", "scss", "sass", "less", "styl",
|
|
58
|
+
"vue", "svelte", "astro", "ejs", "hbs", "pug", "jinja", "j2", "liquid", "twig",
|
|
59
|
+
# 数据 / 配置
|
|
60
|
+
"json", "yml", "yaml", "toml", "ini", "cfg", "conf", "properties",
|
|
61
|
+
"xml", "csv", "tsv", "sql", "graphql", "gql", "proto",
|
|
62
|
+
# 文档 / 脚本
|
|
63
|
+
"md", "markdown", "mdx", "rst", "txt", "adoc", "tex",
|
|
64
|
+
"sh", "bash", "zsh", "fish", "bat", "cmd", "ps1", "psm1",
|
|
65
|
+
}
|
|
66
|
+
# 黑名单目录(隐藏目录另有整体开关,此处只列常见的非隐藏垃圾目录;
|
|
67
|
+
# 点开头的目录即使不在此列表也会被「隐藏目录规则」忽略)
|
|
68
|
+
DEFAULT_EXCLUDE_DIRS = {
|
|
69
|
+
"node_modules", "bower_components", "jspm_packages",
|
|
70
|
+
"__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox",
|
|
71
|
+
"venv", ".venv", "env", "virtualenv",
|
|
72
|
+
"dist", "build", "out", "target", "obj", "bin",
|
|
73
|
+
"vendor", "Pods", "Carthage",
|
|
74
|
+
"coverage", ".nyc_output", ".parcel-cache",
|
|
75
|
+
}
|
|
76
|
+
DEFAULT_EXCLUDE_FILES = {
|
|
77
|
+
"package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
|
78
|
+
"poetry.lock", "pipfile.lock", "composer.lock",
|
|
79
|
+
"cargo.lock", "gemfile.lock",
|
|
80
|
+
}
|
|
81
|
+
DEFAULT_EXCLUDE_PATTERNS = [
|
|
82
|
+
"*.min.js", "*.min.css", "*.map", "*.log",
|
|
83
|
+
"*.pyc", "*.pyo", "*.class",
|
|
84
|
+
"*.o", "*.so", "*.dll", "*.exe", "*.bin",
|
|
85
|
+
"*.woff", "*.woff2", "*.ttf", "*.eot", "*.otf", "*.ico",
|
|
86
|
+
"*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp", "*.svg", "*.webp",
|
|
87
|
+
"*.pdf", "*.zip", "*.tar", "*.gz", "*.rar", "*.7z",
|
|
88
|
+
"*.mp3", "*.mp4", "*.avi", "*.mov",
|
|
89
|
+
"*.db", "*.sqlite",
|
|
90
|
+
]
|
|
91
|
+
# 无扩展名但属于文本的文件名(注意:点开头的【文件】不受隐藏目录规则影响,
|
|
92
|
+
# 只有以 . 开头的【文件夹】会被忽略;根目录下的这类文件仍正常收录)
|
|
93
|
+
DEFAULT_FILENAMES = {
|
|
94
|
+
"dockerfile", "makefile", "rakefile", "gemfile", "procfile",
|
|
95
|
+
"brewfile", "justfile", "vagrantfile",
|
|
96
|
+
"license", "licence", "notice",
|
|
97
|
+
".gitignore", ".gitattributes", ".dockerignore", ".editorconfig",
|
|
98
|
+
".npmrc", ".nvmrc", ".python-version",
|
|
99
|
+
".env.example", ".env.sample",
|
|
100
|
+
}
|
|
101
|
+
# 索引表中展示的人类可读语言名
|
|
102
|
+
LANGUAGE_BY_EXT = {
|
|
103
|
+
"py": "Python", "pyw": "Python",
|
|
104
|
+
"js": "JavaScript", "mjs": "JavaScript", "cjs": "JavaScript",
|
|
105
|
+
"ts": "TypeScript", "tsx": "TypeScript React", "jsx": "JavaScript React",
|
|
106
|
+
"java": "Java",
|
|
107
|
+
"c": "C", "h": "C Header", "cpp": "C++", "cc": "C++", "hpp": "C++ Header",
|
|
108
|
+
"cs": "C#", "go": "Go", "rs": "Rust", "rb": "Ruby", "php": "PHP",
|
|
109
|
+
"swift": "Swift", "kt": "Kotlin", "kts": "Kotlin", "scala": "Scala",
|
|
110
|
+
"dart": "Dart", "lua": "Lua", "pl": "Perl", "r": "R", "jl": "Julia",
|
|
111
|
+
"m": "MATLAB/ObjC",
|
|
112
|
+
"html": "HTML", "htm": "HTML",
|
|
113
|
+
"css": "CSS", "scss": "SCSS", "sass": "Sass", "less": "Less",
|
|
114
|
+
"vue": "Vue", "svelte": "Svelte",
|
|
115
|
+
"json": "JSON", "yml": "YAML", "yaml": "YAML", "toml": "TOML",
|
|
116
|
+
"ini": "INI", "cfg": "Config", "conf": "Config", "env": "Env",
|
|
117
|
+
"xml": "XML", "sql": "SQL", "graphql": "GraphQL", "proto": "Protobuf",
|
|
118
|
+
"md": "Markdown", "markdown": "Markdown", "mdx": "MDX",
|
|
119
|
+
"rst": "reST", "txt": "Text", "csv": "CSV", "tsv": "TSV", "tex": "LaTeX",
|
|
120
|
+
"sh": "Shell", "bash": "Shell", "zsh": "Shell", "fish": "Shell",
|
|
121
|
+
"bat": "Batch", "cmd": "Batch", "ps1": "PowerShell", "psm1": "PowerShell",
|
|
122
|
+
}
|
|
123
|
+
LANGUAGE_BY_NAME = {
|
|
124
|
+
"dockerfile": "Dockerfile", "makefile": "Makefile",
|
|
125
|
+
"rakefile": "Ruby Rake", "gemfile": "Ruby Gemfile", "justfile": "Justfile",
|
|
126
|
+
"license": "License", "licence": "License",
|
|
127
|
+
".gitignore": "Git Ignore", ".dockerignore": "Docker Ignore",
|
|
128
|
+
".editorconfig": "EditorConfig",
|
|
129
|
+
}
|
|
130
|
+
# 代码围栏的语言标识(用于 Markdown 语法高亮)
|
|
131
|
+
FENCE_LANG_BY_EXT = {
|
|
132
|
+
"py": "python", "pyw": "python",
|
|
133
|
+
"js": "javascript", "mjs": "javascript", "cjs": "javascript",
|
|
134
|
+
"ts": "typescript", "tsx": "tsx", "jsx": "jsx",
|
|
135
|
+
"java": "java",
|
|
136
|
+
"c": "c", "h": "c", "cpp": "cpp", "cc": "cpp", "hpp": "cpp",
|
|
137
|
+
"cs": "csharp", "go": "go", "rs": "rust", "rb": "ruby", "php": "php",
|
|
138
|
+
"swift": "swift", "kt": "kotlin", "kts": "kotlin", "scala": "scala",
|
|
139
|
+
"dart": "dart", "lua": "lua", "pl": "perl", "pm": "perl",
|
|
140
|
+
"r": "r", "jl": "julia", "hs": "haskell", "clj": "clojure",
|
|
141
|
+
"ex": "elixir", "exs": "elixir", "erl": "erlang", "groovy": "groovy",
|
|
142
|
+
"asm": "asm", "zig": "zig", "nim": "nim", "v": "v",
|
|
143
|
+
"m": "objective-c", "mm": "objective-c",
|
|
144
|
+
"html": "html", "htm": "html",
|
|
145
|
+
"css": "css", "scss": "scss", "sass": "sass", "less": "less", "styl": "stylus",
|
|
146
|
+
"vue": "vue", "svelte": "svelte", "astro": "astro",
|
|
147
|
+
"ejs": "html", "hbs": "handlebars", "pug": "pug",
|
|
148
|
+
"jinja": "jinja", "j2": "jinja", "liquid": "liquid", "twig": "twig",
|
|
149
|
+
"json": "json", "yml": "yaml", "yaml": "yaml", "toml": "toml",
|
|
150
|
+
"ini": "ini", "cfg": "ini", "conf": "conf", "properties": "properties",
|
|
151
|
+
"xml": "xml", "sql": "sql", "graphql": "graphql", "gql": "graphql",
|
|
152
|
+
"proto": "protobuf",
|
|
153
|
+
"md": "markdown", "markdown": "markdown", "mdx": "markdown",
|
|
154
|
+
"rst": "rst", "adoc": "asciidoc", "txt": "text", "csv": "csv", "tsv": "tsv",
|
|
155
|
+
"tex": "latex",
|
|
156
|
+
"sh": "bash", "bash": "bash", "zsh": "bash", "fish": "fish",
|
|
157
|
+
"bat": "batch", "cmd": "batch", "ps1": "powershell", "psm1": "powershell",
|
|
158
|
+
}
|
|
159
|
+
FENCE_LANG_BY_NAME = {
|
|
160
|
+
"dockerfile": "dockerfile", "makefile": "makefile",
|
|
161
|
+
"rakefile": "ruby", "gemfile": "ruby", "justfile": "makefile",
|
|
162
|
+
"procfile": "text", "brewfile": "ruby", "vagrantfile": "ruby",
|
|
163
|
+
"license": "text", "licence": "text", "notice": "text",
|
|
164
|
+
".gitignore": "gitignore", ".gitattributes": "gitignore",
|
|
165
|
+
".dockerignore": "gitignore", ".editorconfig": "ini",
|
|
166
|
+
".npmrc": "ini", ".nvmrc": "text", ".python-version": "text",
|
|
167
|
+
".env.example": "ini", ".env.sample": "ini",
|
|
168
|
+
}
|
|
169
|
+
# 智能排序:优先级从高到低
|
|
170
|
+
CONFIG_MANIFESTS = {
|
|
171
|
+
"package.json", "pyproject.toml", "setup.py", "setup.cfg",
|
|
172
|
+
"requirements.txt", "go.mod", "go.sum", "cargo.toml",
|
|
173
|
+
"pom.xml", "build.gradle", "composer.json", "gemfile",
|
|
174
|
+
"dockerfile", "docker-compose.yml", "docker-compose.yaml",
|
|
175
|
+
"manage.py", ".env.example",
|
|
176
|
+
}
|
|
177
|
+
ENTRY_STEMS = {"main", "app", "index", "server", "wsgi", "asgi", "__init__", "cli", "run"}
|
|
178
|
+
ENTRY_EXTS = {".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".rb", ".php", ".java"}
|
|
179
|
+
CJK_RE = re.compile(r"[\u3000-\u9fff\uff00-\uffef]")
|
|
180
|
+
CONFIG_TEMPLATE = {
|
|
181
|
+
"_说明": [
|
|
182
|
+
"proj2md 配置文件。命令行参数优先级高于本文件;",
|
|
183
|
+
"不需要的键可直接删除(恢复默认);exts 为空列表 [] 时使用内置默认扩展名。",
|
|
184
|
+
"language:界面语言。auto=跟随系统 / zh=中文 / en=英文。",
|
|
185
|
+
],
|
|
186
|
+
"language": "auto",
|
|
187
|
+
"output": "project_bundle.md",
|
|
188
|
+
"exts": [],
|
|
189
|
+
"any_text": False,
|
|
190
|
+
"exclude_hidden": True,
|
|
191
|
+
"exclude_dirs": [],
|
|
192
|
+
"exclude_files": [],
|
|
193
|
+
"exclude_patterns": [],
|
|
194
|
+
"include_patterns": [],
|
|
195
|
+
"line_numbers": False,
|
|
196
|
+
"max_file_lines": 0,
|
|
197
|
+
"max_file_kb": 512,
|
|
198
|
+
"max_total_kb": 0,
|
|
199
|
+
"split_tokens": 0,
|
|
200
|
+
"show_tree": True,
|
|
201
|
+
"show_index": True,
|
|
202
|
+
"ai_header": True,
|
|
203
|
+
"smart_order": True,
|
|
204
|
+
"clip": False,
|
|
205
|
+
}
|
|
206
|
+
_ASCII_FALLBACK = str.maketrans({
|
|
207
|
+
"═": "=", "─": "-", "├": "|", "└": "`", "│": "|",
|
|
208
|
+
"▶": ">", "✔": "[OK]", "⚠": "[!]", "❌": "[X]", "★": "*",
|
|
209
|
+
"…": "...", "·": "-",
|
|
210
|
+
"(": "(", ")": ")", "「": '"', "」": '"',
|
|
211
|
+
})
|
|
212
|
+
# ═══════════════════════ 多语言系统(zh / en)═══════════════════════
|
|
213
|
+
# 语言解析优先级:--lang 参数 > 配置文件 language 字段 > 系统自动探测 > 英文兜底
|
|
214
|
+
SUPPORTED_LANGS = ("zh", "en")
|
|
215
|
+
LANG_TEXTS = {
|
|
216
|
+
"zh": {
|
|
217
|
+
# ── 命令行帮助 ──
|
|
218
|
+
"cli_desc": "项目源码一键拼接工具:把整个项目合并成单个 Markdown 文档,方便投喂给网页端 AI。",
|
|
219
|
+
"cli_epilog": """\
|
|
220
|
+
忽略规则(按顺序生效):
|
|
221
|
+
隐藏目录(默认开) → 目录黑名单 → 文件黑名单 → 通配符黑名单 → 扩展名白名单
|
|
222
|
+
※ --include-pattern 优先级最高,可穿透所有忽略规则(含隐藏目录)
|
|
223
|
+
常用示例:
|
|
224
|
+
python proj2md.py # 拼接当前目录 -> project_bundle.md
|
|
225
|
+
python proj2md.py myproject -o bundle.md # 指定项目目录与输出文件
|
|
226
|
+
python proj2md.py --only-ext py md # 只拼接 Python 与 Markdown 文件
|
|
227
|
+
python proj2md.py --ext proto graphql # 在默认范围上追加扩展名
|
|
228
|
+
python proj2md.py --exclude-dir tests docs # 额外排除某些目录
|
|
229
|
+
python proj2md.py --include-pattern "src/*" # 强制包含匹配的文件(优先级最高)
|
|
230
|
+
python proj2md.py --include-hidden # 不忽略以 . 开头的文件夹
|
|
231
|
+
python proj2md.py --include-pattern ".github/*" # 只捞回某个隐藏目录的内容
|
|
232
|
+
python proj2md.py --lang en # 界面切英文(auto/zh/en)
|
|
233
|
+
python proj2md.py --line-numbers # 正文带行号,AI 引用更精准
|
|
234
|
+
python proj2md.py --max-file-lines 300 # 单文件超过 300 行则截断
|
|
235
|
+
python proj2md.py --max-total-kb 200 # 总体积预算 200KB
|
|
236
|
+
python proj2md.py --split-tokens 60000 # 体积过大时自动切成多个 .md 分卷
|
|
237
|
+
python proj2md.py --prompt "帮我审查代码" --clip # 附带需求并复制到剪贴板
|
|
238
|
+
python proj2md.py --dry-run # 预览将拼接哪些文件
|
|
239
|
+
python proj2md.py --init-config # 生成 proj2md.json 配置模板
|
|
240
|
+
说明:
|
|
241
|
+
通配符规则同 fnmatch,* 可跨目录层级(如 "src/*" 匹配 src 下所有文件)。""",
|
|
242
|
+
"arg_help": "显示本帮助信息并退出",
|
|
243
|
+
"arg_root": "项目根目录(默认当前目录)",
|
|
244
|
+
"arg_output": "输出文件路径(默认 {out})",
|
|
245
|
+
"arg_ext": "在默认范围上追加扩展名,如 --ext py md",
|
|
246
|
+
"arg_only_ext": "只包含指定扩展名(替换默认范围)",
|
|
247
|
+
"arg_any_text": "包含所有非二进制文本文件(忽略扩展名白名单)",
|
|
248
|
+
"arg_include_hidden": "不忽略以 . 开头的文件夹(默认忽略;--include-pattern 仍可单独捞回)",
|
|
249
|
+
"arg_exclude_dir": "额外排除的目录名",
|
|
250
|
+
"arg_exclude_file": "额外排除的文件名",
|
|
251
|
+
"arg_exclude_pattern": "额外排除的通配符,如 *.min.js tests/*",
|
|
252
|
+
"arg_include_pattern": "强制包含的通配符(优先级最高,可穿透一切忽略规则)",
|
|
253
|
+
"arg_lang": "界面语言: auto=跟随系统 / zh=中文 / en=英文(默认 auto)",
|
|
254
|
+
"arg_line_numbers": "正文每行前加行号,便于 AI 精确引用",
|
|
255
|
+
"arg_max_file_lines": "单文件最多保留 N 行,超出截断(0=不限制)",
|
|
256
|
+
"arg_max_file_kb": "超过此大小的文件直接跳过(默认 512)",
|
|
257
|
+
"arg_max_total_kb": "合集总大小预算(KB),超出后停止追加文件",
|
|
258
|
+
"arg_split_tokens": "按 token 预估把合集切成多个 .md 文件(如 --split-tokens 60000)",
|
|
259
|
+
"arg_no_tree": "不输出目录结构",
|
|
260
|
+
"arg_no_index": "不输出文件索引",
|
|
261
|
+
"arg_no_ai_header": "不输出「给 AI 的阅读说明」",
|
|
262
|
+
"arg_no_smart_order": "禁用智能排序(README/配置/入口优先)",
|
|
263
|
+
"arg_prompt": "附带你的需求/问题,将置于合集最前",
|
|
264
|
+
"arg_prompt_file": "从文件读取需求描述(UTF-8)",
|
|
265
|
+
"arg_clip": "生成后复制到系统剪贴板",
|
|
266
|
+
"arg_stdout": "输出到标准输出而不写文件",
|
|
267
|
+
"arg_dry_run": "只预览将拼接的文件与统计,不生成",
|
|
268
|
+
"arg_config": "指定配置文件(默认自动查找 {cfg})",
|
|
269
|
+
"arg_no_config": "忽略已存在的配置文件",
|
|
270
|
+
"arg_init_config": "生成 {cfg} 模板后退出",
|
|
271
|
+
"arg_quiet": "静默模式,只输出结果路径",
|
|
272
|
+
"arg_version": "显示版本号",
|
|
273
|
+
# ── 主流程消息 ──
|
|
274
|
+
"err_root_not_dir": "错误:项目目录不存在或不是目录: {root}",
|
|
275
|
+
"err_config_exists": "错误:配置文件已存在: {path}(如需重新生成请先删除)",
|
|
276
|
+
"ok_config_created": "✔ 已生成配置模板: {path}",
|
|
277
|
+
"config_hint": " 按需修改后再次运行 proj2md 即可自动读取(命令行参数优先级更高)。language 字段可设 auto / zh / en 切换界面语言。",
|
|
278
|
+
"info_config_loaded": "· 已加载配置文件: {path}",
|
|
279
|
+
"warn_config_parse": "警告:配置文件解析失败({err}),已忽略。",
|
|
280
|
+
"err_config_root": "配置根节点必须是 JSON 对象",
|
|
281
|
+
"err_no_files": "错误:没有找到任何可拼接的文件。可用 --ext / --include-pattern / --any-text / --include-hidden 调整范围。",
|
|
282
|
+
"err_all_skipped": "错误:所有候选文件都被跳过(过大 / 二进制 / 预算不足)。",
|
|
283
|
+
"warn_prompt_file": "警告:读取 --prompt-file 失败({err}),已忽略。",
|
|
284
|
+
"part_label": " · 第 {i}/{n} 部分",
|
|
285
|
+
"ok_part_generated": "✔ {name} ({files} 个文件 · ~{tokens} tokens · {size})",
|
|
286
|
+
"split_hint": "\n提示: 已按 --split-tokens={n} 切成 {total} 卷,请按 part1 → part2 顺序投喂。",
|
|
287
|
+
"ok_clipboard": "✔ 已复制到剪贴板(via {how})",
|
|
288
|
+
"err_clipboard": "⚠ 复制到剪贴板失败:建议 pip install pyperclip,或手动打开输出文件复制。",
|
|
289
|
+
"cancelled": "\n已取消。",
|
|
290
|
+
# ── 汇总报告 ──
|
|
291
|
+
"sum_generated": "✔ 已生成: {path}",
|
|
292
|
+
"sum_files": " ├─ 文件 : {n} 个",
|
|
293
|
+
"sum_files_skipped": " ├─ 文件 : {n} 个(跳过 {skipped} 个)",
|
|
294
|
+
"sum_hidden": " ├─ 隐藏目录 : 已忽略 {n} 个以 . 开头的文件夹(--include-hidden 可包含)",
|
|
295
|
+
"sum_lines": " ├─ 行数 : {lines}",
|
|
296
|
+
"sum_size": " ├─ 大小 : {size}(UTF-8 Markdown)",
|
|
297
|
+
"sum_tokens": " └─ Token预估: ~{tokens} → {hint}",
|
|
298
|
+
"sum_tip1": "提示: 直接把 .md 内容粘贴给网页 AI —— Markdown 代码块会自动语法高亮,AI 定位文件更轻松。",
|
|
299
|
+
"sum_tip2": " 常用组合: --line-numbers 精确引用行号 · --clip 复制到剪贴板 · --prompt \"你的需求\"",
|
|
300
|
+
# ── dry-run 预览 ──
|
|
301
|
+
"dry_preview": "· 预览:以下 {n} 个文件将被拼接(共 {lines} 行,~{tokens} tokens)",
|
|
302
|
+
"dry_file_item": " {i}. {path} ({lang}, {lines} 行, {size}){flag}",
|
|
303
|
+
"dry_truncated_flag": " [截断]",
|
|
304
|
+
"dry_skipped_head": "\n· 另有 {n} 个文件将被跳过:",
|
|
305
|
+
"dry_skip_item": " - {rel}({reason})",
|
|
306
|
+
"dry_more": " ……及另外 {n} 个",
|
|
307
|
+
"dry_hidden_head": "\n· 已忽略 {n} 个隐藏目录(以 . 开头,--include-hidden 可包含):",
|
|
308
|
+
"dry_tokens": "· Token 预估: ~{tokens} → {hint}",
|
|
309
|
+
"dry_dryrun": "· dry-run 模式,未写入任何文件",
|
|
310
|
+
# ── Token 体量提示 ──
|
|
311
|
+
"hint_moderate": "✅ 体量适中,可直接粘贴给绝大多数网页 AI",
|
|
312
|
+
"hint_long": "⚠️ 较长,部分 AI 输入框有长度限制,建议裁剪或分卷",
|
|
313
|
+
"hint_very_long": "⚠️ 很长,仅长上下文模型(Claude/Gemini 等)能完整读取,建议 --split-tokens 分卷",
|
|
314
|
+
"hint_too_long": "❌ 过长,强烈建议 --exclude-dir / --max-file-lines / --only-ext / --split-tokens 裁剪",
|
|
315
|
+
# ── 跳过原因 / 读取错误 ──
|
|
316
|
+
"skip_unreadable": "无法读取({cls})",
|
|
317
|
+
"skip_too_large": "超过单文件上限 {kb:g} KB(实际 {size}),可用 --max-file-kb 调整",
|
|
318
|
+
"skip_read_err": "{err},未纳入",
|
|
319
|
+
"skip_over_budget": "超出 --max-total-kb 总预算,未纳入",
|
|
320
|
+
"read_fail": "读取失败 {cls}",
|
|
321
|
+
"looks_binary": "疑似二进制",
|
|
322
|
+
"undecodable": "无法解码",
|
|
323
|
+
"truncated_note": "……(该文件共 {orig} 行,超过 --max-file-lines={keep} 限制,此处仅保留前 {keep} 行)\n",
|
|
324
|
+
"empty_file": "(空文件)\n",
|
|
325
|
+
# ── 生成的 Markdown 文档 ──
|
|
326
|
+
"doc_title": "# 项目代码合集:{root}{label}",
|
|
327
|
+
"doc_meta_time": "**生成时间**:{now}",
|
|
328
|
+
"doc_meta_project": "**项目名称**:{root}",
|
|
329
|
+
"doc_meta_files": "**文件数量**:{n} 个",
|
|
330
|
+
"doc_meta_files_skipped": "**文件数量**:{n} 个(另有 {skipped} 个被跳过,见文末附录)",
|
|
331
|
+
"doc_meta_lines": "**代码行数**:{lines} 行",
|
|
332
|
+
"doc_meta_size": "**代码体积**:{size}",
|
|
333
|
+
"doc_meta_tokens": "**Token 预估**:约 {tokens}(粗略估算,实际以平台为准)",
|
|
334
|
+
"doc_ai_header": "## 📖 给 AI 的阅读说明\n\n",
|
|
335
|
+
"ai_notes": """\
|
|
336
|
+
本文件是「{root}」项目的源码拼接合集(Markdown 格式),由 proj2md 工具生成。请按以下约定阅读:
|
|
337
|
+
1. **目录结构**=项目整体布局;**文件索引**=各文件的路径 / 语言 / 行数,其中「起始行」为该文件正文在本文件中的行号,可用于快速定位。
|
|
338
|
+
2. 每个源文件对应「源代码正文」中的一个三级标题(`### 序号. 相对路径`),其正文位于紧随其后的围栏代码块中,围栏开头标注了语言标识。所有路径均相对项目根目录。
|
|
339
|
+
3. 个别文件若被截断,其代码块末尾会有一行「……该文件共 N 行……」的提示。
|
|
340
|
+
4. 引用代码时请使用「相对路径:行号」格式(例如 `src/main.py:42`);若正文行首带有「 行号 | 」前缀,请以该前缀中的数字为文件内行号。
|
|
341
|
+
5. 若「我的需求」中没有给出具体任务,请先简要总结项目结构与所用技术栈,再等待我的进一步指示。""",
|
|
342
|
+
"doc_prompt": "## 🎯 我的需求(请优先阅读)\n\n",
|
|
343
|
+
"doc_tree": "## 🗂 目录结构\n\n",
|
|
344
|
+
"doc_index": "## 📑 文件索引\n\n",
|
|
345
|
+
"doc_index_cols": "| # | 文件路径 | 语言 | 行数 | 起始行 |",
|
|
346
|
+
"doc_source": "## 📄 源代码正文\n\n",
|
|
347
|
+
"doc_lines_unit": "{n} 行",
|
|
348
|
+
"doc_encoding": "编码 `{enc}`",
|
|
349
|
+
"doc_truncated": "**已截断**",
|
|
350
|
+
"doc_appendix_skipped": "## 📎 附录:未包含的文件\n\n",
|
|
351
|
+
"doc_skip_item": "- {path}({reason})",
|
|
352
|
+
"doc_more_skipped": "- ……另有 {n} 个文件未列出",
|
|
353
|
+
"doc_appendix_hidden": "## 📎 附录:已忽略的隐藏目录(以 . 开头)\n\n"
|
|
354
|
+
"如需包含这些目录,请加 `--include-hidden`,或用 "
|
|
355
|
+
"`--include-pattern \"<目录名>/*\"` 捞回特定目录:\n\n",
|
|
356
|
+
"doc_hidden_item": "- {path}/(隐藏目录,默认忽略)",
|
|
357
|
+
"doc_more_hidden": "- ……另有 {n} 个隐藏目录未列出",
|
|
358
|
+
"doc_end": "---\n\n*END · 共 {n} 个文件 · {lines} 行 · 约 {tokens} tokens · 由 {tool} v{ver} 生成于 {now}*",
|
|
359
|
+
},
|
|
360
|
+
"en": {
|
|
361
|
+
# ── CLI help ──
|
|
362
|
+
"cli_desc": "Project source bundler: merges a whole project into a single Markdown document, ready to paste into web-based AIs.",
|
|
363
|
+
"cli_epilog": """\
|
|
364
|
+
Ignore rules (applied in order):
|
|
365
|
+
hidden dirs (on by default) → dir blacklist → file blacklist → glob blacklist → extension whitelist
|
|
366
|
+
※ --include-pattern has top priority and pierces all ignore rules (incl. hidden dirs)
|
|
367
|
+
Common examples:
|
|
368
|
+
python proj2md.py # bundle current dir -> project_bundle.md
|
|
369
|
+
python proj2md.py myproject -o bundle.md # specify project dir and output file
|
|
370
|
+
python proj2md.py --only-ext py md # bundle only Python and Markdown files
|
|
371
|
+
python proj2md.py --ext proto graphql # add extensions on top of defaults
|
|
372
|
+
python proj2md.py --exclude-dir tests docs # exclude extra directories
|
|
373
|
+
python proj2md.py --include-pattern "src/*" # force-include matching files (top priority)
|
|
374
|
+
python proj2md.py --include-hidden # don't ignore dot-prefixed folders
|
|
375
|
+
python proj2md.py --include-pattern ".github/*" # fish back one hidden dir's contents
|
|
376
|
+
python proj2md.py --lang zh # switch UI to Chinese (auto/zh/en)
|
|
377
|
+
python proj2md.py --line-numbers # line-numbered body for precise AI references
|
|
378
|
+
python proj2md.py --max-file-lines 300 # truncate files beyond 300 lines
|
|
379
|
+
python proj2md.py --max-total-kb 200 # 200KB total budget
|
|
380
|
+
python proj2md.py --split-tokens 60000 # auto-split into several .md volumes
|
|
381
|
+
python proj2md.py --prompt "review my code" --clip # attach request and copy to clipboard
|
|
382
|
+
python proj2md.py --dry-run # preview only, no file written
|
|
383
|
+
python proj2md.py --init-config # generate proj2md.json template
|
|
384
|
+
Notes:
|
|
385
|
+
Glob rules follow fnmatch; * spans directory levels (e.g. "src/*" matches everything under src).""",
|
|
386
|
+
"arg_help": "show this help message and exit",
|
|
387
|
+
"arg_root": "project root directory (default: current directory)",
|
|
388
|
+
"arg_output": "output file path (default: {out})",
|
|
389
|
+
"arg_ext": "add extensions on top of the default set, e.g. --ext py md",
|
|
390
|
+
"arg_only_ext": "include only these extensions (replaces the default set)",
|
|
391
|
+
"arg_any_text": "include every non-binary text file (ignores the extension whitelist)",
|
|
392
|
+
"arg_include_hidden": "do not ignore dot-prefixed folders (ignored by default; --include-pattern can still fish one back)",
|
|
393
|
+
"arg_exclude_dir": "extra directory names to exclude",
|
|
394
|
+
"arg_exclude_file": "extra file names to exclude",
|
|
395
|
+
"arg_exclude_pattern": "extra glob patterns to exclude, e.g. *.min.js tests/*",
|
|
396
|
+
"arg_include_pattern": "glob patterns to force-include (highest priority; pierces all ignore rules)",
|
|
397
|
+
"arg_lang": "UI language: auto = follow system / zh = Chinese / en = English (default: auto)",
|
|
398
|
+
"arg_line_numbers": "prefix each body line with its number so the AI can cite precisely",
|
|
399
|
+
"arg_max_file_lines": "keep at most N lines per file, truncate the rest (0 = unlimited)",
|
|
400
|
+
"arg_max_file_kb": "skip files larger than this many KB (default 512)",
|
|
401
|
+
"arg_max_total_kb": "total size budget for the bundle (KB); stop adding files once exceeded",
|
|
402
|
+
"arg_split_tokens": "split the bundle into several .md files by estimated tokens (e.g. --split-tokens 60000)",
|
|
403
|
+
"arg_no_tree": "omit the directory tree section",
|
|
404
|
+
"arg_no_index": "omit the file index section",
|
|
405
|
+
"arg_no_ai_header": "omit the 'Reading Notes for AI' header",
|
|
406
|
+
"arg_no_smart_order": "disable smart ordering (README / config / entry files first)",
|
|
407
|
+
"arg_prompt": "attach your request/question at the very top of the bundle",
|
|
408
|
+
"arg_prompt_file": "read the request description from a file (UTF-8)",
|
|
409
|
+
"arg_clip": "copy the result to the system clipboard after generating",
|
|
410
|
+
"arg_stdout": "print to stdout instead of writing a file",
|
|
411
|
+
"arg_dry_run": "preview the files and stats without writing anything",
|
|
412
|
+
"arg_config": "config file to use (default: auto-look-up {cfg})",
|
|
413
|
+
"arg_no_config": "ignore any existing config file",
|
|
414
|
+
"arg_init_config": "write a {cfg} template, then exit",
|
|
415
|
+
"arg_quiet": "quiet mode; print only the result path",
|
|
416
|
+
"arg_version": "show version and exit",
|
|
417
|
+
# ── main-flow messages ──
|
|
418
|
+
"err_root_not_dir": "Error: project directory does not exist or is not a directory: {root}",
|
|
419
|
+
"err_config_exists": "Error: config file already exists: {path} (delete it first if you want to regenerate)",
|
|
420
|
+
"ok_config_created": "✔ Config template created: {path}",
|
|
421
|
+
"config_hint": " Edit it as needed, then run proj2md again — it is loaded automatically (CLI arguments take priority). Set \"language\" to auto / zh / en to switch the UI language.",
|
|
422
|
+
"info_config_loaded": "· Config file loaded: {path}",
|
|
423
|
+
"warn_config_parse": "Warning: failed to parse config file ({err}); ignored.",
|
|
424
|
+
"err_config_root": "config root must be a JSON object",
|
|
425
|
+
"err_no_files": "Error: no files found to bundle. Adjust the scope with --ext / --include-pattern / --any-text / --include-hidden.",
|
|
426
|
+
"err_all_skipped": "Error: every candidate file was skipped (too large / binary / over budget).",
|
|
427
|
+
"warn_prompt_file": "Warning: failed to read --prompt-file ({err}); ignored.",
|
|
428
|
+
"part_label": " · Part {i}/{n}",
|
|
429
|
+
"ok_part_generated": "✔ {name} ({files} files · ~{tokens} tokens · {size})",
|
|
430
|
+
"split_hint": "\nTip: split into {total} parts by --split-tokens={n}; feed them to the AI in order (part1 → part2 …).",
|
|
431
|
+
"ok_clipboard": "✔ Copied to clipboard (via {how})",
|
|
432
|
+
"err_clipboard": "⚠ Failed to copy to clipboard: try pip install pyperclip, or copy from the output file manually.",
|
|
433
|
+
"cancelled": "\nCancelled.",
|
|
434
|
+
# ── summary report ──
|
|
435
|
+
"sum_generated": "✔ Generated: {path}",
|
|
436
|
+
"sum_files": " ├─ Files : {n}",
|
|
437
|
+
"sum_files_skipped": " ├─ Files : {n} ({skipped} skipped)",
|
|
438
|
+
"sum_hidden": " ├─ Hidden dirs : {n} dot-prefixed folders ignored (--include-hidden to include)",
|
|
439
|
+
"sum_lines": " ├─ Lines : {lines}",
|
|
440
|
+
"sum_size": " ├─ Size : {size} (UTF-8 Markdown)",
|
|
441
|
+
"sum_tokens": " └─ Token est.: ~{tokens} → {hint}",
|
|
442
|
+
"sum_tip1": "Tip: paste the .md straight into a web AI — code blocks get automatic syntax highlighting, which makes file references easier for the AI.",
|
|
443
|
+
"sum_tip2": " Common flags: --line-numbers for precise line refs · --clip to copy · --prompt \"your task\"",
|
|
444
|
+
# ── dry-run preview ──
|
|
445
|
+
"dry_preview": "· Preview: {n} files will be bundled ({lines} lines, ~{tokens} tokens)",
|
|
446
|
+
"dry_file_item": " {i}. {path} ({lang}, {lines} lines, {size}){flag}",
|
|
447
|
+
"dry_truncated_flag": " [truncated]",
|
|
448
|
+
"dry_skipped_head": "\n· {n} more files will be skipped:",
|
|
449
|
+
"dry_skip_item": " - {rel} ({reason})",
|
|
450
|
+
"dry_more": " ...and {n} more",
|
|
451
|
+
"dry_hidden_head": "\n· {n} hidden directories ignored (dot-prefixed; --include-hidden to include):",
|
|
452
|
+
"dry_tokens": "· Token estimate: ~{tokens} → {hint}",
|
|
453
|
+
"dry_dryrun": "· dry-run mode: nothing was written",
|
|
454
|
+
# ── token size hints ──
|
|
455
|
+
"hint_moderate": "✅ Moderate size — can be pasted directly into most web AIs",
|
|
456
|
+
"hint_long": "⚠️ Long — some AI input boxes have length limits; consider trimming or splitting",
|
|
457
|
+
"hint_very_long": "⚠️ Very long — only long-context models (Claude/Gemini etc.) can read it fully; consider --split-tokens",
|
|
458
|
+
"hint_too_long": "❌ Too long — strongly consider trimming with --exclude-dir / --max-file-lines / --only-ext / --split-tokens",
|
|
459
|
+
# ── skip reasons / read errors ──
|
|
460
|
+
"skip_unreadable": "unreadable ({cls})",
|
|
461
|
+
"skip_too_large": "exceeds per-file limit {kb:g} KB (actual {size}); adjust via --max-file-kb",
|
|
462
|
+
"skip_read_err": "{err}; excluded",
|
|
463
|
+
"skip_over_budget": "exceeds --max-total-kb total budget; excluded",
|
|
464
|
+
"read_fail": "read failed: {cls}",
|
|
465
|
+
"looks_binary": "looks binary",
|
|
466
|
+
"undecodable": "undecodable",
|
|
467
|
+
"truncated_note": "...(the file has {orig} lines in total, beyond the --max-file-lines={keep} limit; only the first {keep} lines are kept)\n",
|
|
468
|
+
"empty_file": "(empty file)\n",
|
|
469
|
+
# ── generated Markdown document ──
|
|
470
|
+
"doc_title": "# Project Code Bundle: {root}{label}",
|
|
471
|
+
"doc_meta_time": "**Generated at**: {now}",
|
|
472
|
+
"doc_meta_project": "**Project**: {root}",
|
|
473
|
+
"doc_meta_files": "**Files**: {n}",
|
|
474
|
+
"doc_meta_files_skipped": "**Files**: {n} ({skipped} more skipped — see the appendix at the end)",
|
|
475
|
+
"doc_meta_lines": "**Lines of code**: {lines}",
|
|
476
|
+
"doc_meta_size": "**Code size**: {size}",
|
|
477
|
+
"doc_meta_tokens": "**Token estimate**: ~{tokens} (rough; varies by platform)",
|
|
478
|
+
"doc_ai_header": "## 📖 Reading Notes for AI\n\n",
|
|
479
|
+
"ai_notes": """\
|
|
480
|
+
This file is a Markdown bundle of the source code of the "{root}" project, generated by the proj2md tool. Please read it with these conventions:
|
|
481
|
+
1. **Directory Tree** = overall layout; **File Index** = each file's path / language / line count, where "Start" is the line number where that file's body begins in this document — handy for quick lookup.
|
|
482
|
+
2. Each source file corresponds to one third-level heading under "Source Code" (`### No. relative/path`); its body sits in the fenced code block right below the heading, with a language tag at the opening fence. All paths are relative to the project root.
|
|
483
|
+
3. If a file was truncated, the last line of its code block will say "... the file has N lines in total ...".
|
|
484
|
+
4. When citing code, use the "relative/path:line" format (e.g. `src/main.py:42`); if body lines carry a " line | " prefix, use the number in that prefix as the in-file line number.
|
|
485
|
+
5. If "My Request" contains no specific task, first summarize the project structure and tech stack, then wait for further instructions.""",
|
|
486
|
+
"doc_prompt": "## 🎯 My Request (please read first)\n\n",
|
|
487
|
+
"doc_tree": "## 🗂 Directory Tree\n\n",
|
|
488
|
+
"doc_index": "## 📑 File Index\n\n",
|
|
489
|
+
"doc_index_cols": "| # | File Path | Language | Lines | Start |",
|
|
490
|
+
"doc_source": "## 📄 Source Code\n\n",
|
|
491
|
+
"doc_lines_unit": "{n} lines",
|
|
492
|
+
"doc_encoding": "encoding `{enc}`",
|
|
493
|
+
"doc_truncated": "**truncated**",
|
|
494
|
+
"doc_appendix_skipped": "## 📎 Appendix: Files Not Included\n\n",
|
|
495
|
+
"doc_skip_item": "- {path} ({reason})",
|
|
496
|
+
"doc_more_skipped": "- ...and {n} more files not listed",
|
|
497
|
+
"doc_appendix_hidden": "## 📎 Appendix: Ignored Hidden Directories (dot-prefixed)\n\n"
|
|
498
|
+
"To include them, pass `--include-hidden`, or fish one back with "
|
|
499
|
+
"`--include-pattern \"<dir>/*\"`:\n\n",
|
|
500
|
+
"doc_hidden_item": "- {path}/ (hidden dir, ignored by default)",
|
|
501
|
+
"doc_more_hidden": "- ...and {n} more hidden dirs not listed",
|
|
502
|
+
"doc_end": "---\n\n*END · {n} files · {lines} lines · ~{tokens} tokens · generated by {tool} v{ver} at {now}*",
|
|
503
|
+
},
|
|
504
|
+
}
|
|
505
|
+
_CURRENT_LANG = "zh" # 当前界面语言,由 set_lang() 写入
|
|
506
|
+
def detect_system_lang() -> str:
|
|
507
|
+
"""探测操作系统默认语言:任一来源的 locale 以 zh 开头 → 'zh',否则 'en'。
|
|
508
|
+
依次尝试:环境变量 → locale 模块(抑制弃用警告)→ Windows 用户 UI 语言 API。"""
|
|
509
|
+
codes = []
|
|
510
|
+
for var in ("LC_ALL", "LC_MESSAGES", "LC_CTYPE", "LANG", "LANGUAGE"):
|
|
511
|
+
v = os.environ.get(var)
|
|
512
|
+
if v:
|
|
513
|
+
codes.append(v)
|
|
514
|
+
try:
|
|
515
|
+
with warnings.catch_warnings():
|
|
516
|
+
warnings.simplefilter("ignore", DeprecationWarning)
|
|
517
|
+
loc = locale.getdefaultlocale()
|
|
518
|
+
if loc and loc[0]:
|
|
519
|
+
codes.append(loc[0])
|
|
520
|
+
except Exception:
|
|
521
|
+
pass
|
|
522
|
+
if sys.platform == "win32":
|
|
523
|
+
try:
|
|
524
|
+
import ctypes
|
|
525
|
+
lid = ctypes.windll.kernel32.GetUserDefaultUILanguage()
|
|
526
|
+
name = locale.windows_locale.get(lid, "")
|
|
527
|
+
if name:
|
|
528
|
+
codes.append(name)
|
|
529
|
+
except Exception:
|
|
530
|
+
pass
|
|
531
|
+
for c in codes:
|
|
532
|
+
if c and str(c).lower().startswith("zh"):
|
|
533
|
+
return "zh"
|
|
534
|
+
return "en"
|
|
535
|
+
def set_lang(lang) -> str:
|
|
536
|
+
"""解析并设置界面语言。'auto'/None/空 → 探测系统;'zh-CN'/'en_US.UTF-8'
|
|
537
|
+
之类自动取主语言码;未支持的语言回退英文。返回最终生效语言。"""
|
|
538
|
+
global _CURRENT_LANG
|
|
539
|
+
s = str(lang if lang is not None else "auto").strip().lower()
|
|
540
|
+
if s in ("", "auto", "system", "default"):
|
|
541
|
+
_CURRENT_LANG = detect_system_lang()
|
|
542
|
+
else:
|
|
543
|
+
s = s.replace("-", "_").split("_")[0].split(".")[0]
|
|
544
|
+
_CURRENT_LANG = s if s in LANG_TEXTS else "en"
|
|
545
|
+
return _CURRENT_LANG
|
|
546
|
+
def t(key: str, **kw) -> str:
|
|
547
|
+
"""取当前语言的文案;缺 key 时回退英文,再缺则返回 key 本身。
|
|
548
|
+
无 kw 时不做 format(避免文案中的花括号引发异常)。"""
|
|
549
|
+
text = LANG_TEXTS.get(_CURRENT_LANG, {}).get(key)
|
|
550
|
+
if text is None:
|
|
551
|
+
text = LANG_TEXTS["en"].get(key, key)
|
|
552
|
+
return text.format(**kw) if kw else text
|
|
553
|
+
# ─────────────────────────── 数据结构 ───────────────────────────
|
|
554
|
+
@dataclass
|
|
555
|
+
class FileRec:
|
|
556
|
+
rel: Path
|
|
557
|
+
abspath: Path
|
|
558
|
+
language: str = ""
|
|
559
|
+
encoding: str = ""
|
|
560
|
+
content: str = ""
|
|
561
|
+
lines: int = 0
|
|
562
|
+
chars: int = 0
|
|
563
|
+
nbytes: int = 0
|
|
564
|
+
truncated: bool = False
|
|
565
|
+
orig_lines: int = 0
|
|
566
|
+
@dataclass
|
|
567
|
+
class Config:
|
|
568
|
+
root: Path
|
|
569
|
+
output: Path
|
|
570
|
+
exts: set
|
|
571
|
+
any_text: bool
|
|
572
|
+
exclude_hidden: bool # 是否忽略以 . 开头的文件夹(默认 True)
|
|
573
|
+
exclude_dirs: set
|
|
574
|
+
exclude_files: set
|
|
575
|
+
exclude_patterns: list
|
|
576
|
+
include_patterns: list
|
|
577
|
+
line_numbers: bool
|
|
578
|
+
max_file_lines: int
|
|
579
|
+
max_file_kb: float
|
|
580
|
+
max_total_kb: float
|
|
581
|
+
split_tokens: int
|
|
582
|
+
show_tree: bool
|
|
583
|
+
show_index: bool
|
|
584
|
+
ai_header: bool
|
|
585
|
+
smart_order: bool
|
|
586
|
+
clip: bool
|
|
587
|
+
config_path: Path
|
|
588
|
+
# ─────────────────────────── 小工具 ───────────────────────────
|
|
589
|
+
def cprint(*args, **kw):
|
|
590
|
+
"""安全打印:终端编码不支持中文符号时自动降级为 ASCII。"""
|
|
591
|
+
s = " ".join(str(a) for a in args)
|
|
592
|
+
try:
|
|
593
|
+
print(s, **kw)
|
|
594
|
+
except UnicodeEncodeError:
|
|
595
|
+
print(s.translate(_ASCII_FALLBACK), **kw)
|
|
596
|
+
def normalize_ext(e: str) -> str:
|
|
597
|
+
return str(e).strip().lower().lstrip(".")
|
|
598
|
+
def fmt_size(n) -> str:
|
|
599
|
+
n = float(n)
|
|
600
|
+
for u in ("B", "KB", "MB", "GB"):
|
|
601
|
+
if n < 1024 or u == "GB":
|
|
602
|
+
return f"{n:.0f} {u}" if u == "B" else f"{n:.1f} {u}"
|
|
603
|
+
n /= 1024
|
|
604
|
+
return f"{n:.1f} GB"
|
|
605
|
+
def estimate_tokens(text: str) -> int:
|
|
606
|
+
"""粗略估算 token:中文按 ~1.1 token/字,其他按 ~3.8 字符/token。"""
|
|
607
|
+
cjk = len(CJK_RE.findall(text))
|
|
608
|
+
return int(cjk * 1.1 + (len(text) - cjk) / 3.8)
|
|
609
|
+
def token_hint(tok: int) -> str:
|
|
610
|
+
if tok < 30_000:
|
|
611
|
+
return t("hint_moderate")
|
|
612
|
+
if tok < 100_000:
|
|
613
|
+
return t("hint_long")
|
|
614
|
+
if tok < 200_000:
|
|
615
|
+
return t("hint_very_long")
|
|
616
|
+
return t("hint_too_long")
|
|
617
|
+
def lang_of(p: Path) -> str:
|
|
618
|
+
name_l = p.name.lower()
|
|
619
|
+
if name_l in LANGUAGE_BY_NAME:
|
|
620
|
+
return LANGUAGE_BY_NAME[name_l]
|
|
621
|
+
ext = p.suffix.lower().lstrip(".")
|
|
622
|
+
if ext in LANGUAGE_BY_EXT:
|
|
623
|
+
return LANGUAGE_BY_EXT[ext]
|
|
624
|
+
return ext.upper() if ext else "Text"
|
|
625
|
+
# ─────────────────────────── Markdown 辅助 ───────────────────────────
|
|
626
|
+
def fence_for(content: str) -> str:
|
|
627
|
+
"""计算安全的围栏长度:比正文中最长的反引号串多 1 个,
|
|
628
|
+
这样即使源码里含有 ``` 代码块也不会截断外层围栏。"""
|
|
629
|
+
longest = max((len(m.group(0)) for m in re.finditer(r"`+", content)), default=0)
|
|
630
|
+
return "`" * max(3, longest + 1)
|
|
631
|
+
def fence_lang_of(p: Path) -> str:
|
|
632
|
+
"""返回代码围栏的语言标识(用于语法高亮)。"""
|
|
633
|
+
name_l = p.name.lower()
|
|
634
|
+
if name_l in FENCE_LANG_BY_NAME:
|
|
635
|
+
return FENCE_LANG_BY_NAME[name_l]
|
|
636
|
+
ext = p.suffix.lower().lstrip(".")
|
|
637
|
+
return FENCE_LANG_BY_EXT.get(ext, ext)
|
|
638
|
+
def md_slug(text: str) -> str:
|
|
639
|
+
"""GitHub 风格标题锚点:小写、去标点、空格转连字符(保留中文/字母/数字/连字符)。"""
|
|
640
|
+
s = text.strip().lower()
|
|
641
|
+
s = re.sub(r"[^\w\- ]", "", s)
|
|
642
|
+
return s.replace(" ", "-")
|
|
643
|
+
def md_code_span(s: str) -> str:
|
|
644
|
+
"""生成行内代码;含反引号时退化为纯文本。"""
|
|
645
|
+
return f"`{s}`" if "`" not in s else s
|
|
646
|
+
def md_table_cell(s: str) -> str:
|
|
647
|
+
"""表格单元格里的行内代码:竖线必须转义(表格内代码span也不例外)。"""
|
|
648
|
+
if "`" in s:
|
|
649
|
+
return s.replace("|", "\\|").replace("[", "\\[").replace("]", "\\]")
|
|
650
|
+
esc = s.replace("|", "\\|")
|
|
651
|
+
return f"`{esc}`"
|
|
652
|
+
# ─────────────────────────── 文件发现与读取 ───────────────────────────
|
|
653
|
+
def _match_any(rel_posix: str, name_l: str, patterns) -> bool:
|
|
654
|
+
for pat in patterns:
|
|
655
|
+
pat_l = str(pat).lower()
|
|
656
|
+
if fnmatch.fnmatch(name_l, pat_l) or fnmatch.fnmatch(rel_posix, pat_l):
|
|
657
|
+
return True
|
|
658
|
+
return False
|
|
659
|
+
def _dir_may_be_included(dir_rel: str, patterns) -> bool:
|
|
660
|
+
"""判断某个目录是否可能被 --include-pattern 覆盖(用于让强制包含
|
|
661
|
+
穿透「隐藏目录忽略」)。取每个 pattern 第一个 * 之前的字面前缀:
|
|
662
|
+
- 前缀为空(如 "*"、"*.md")→ 可能覆盖一切目录 → True
|
|
663
|
+
- 目录路径以前缀开头(如 ".github" vs ".github/*")→ True
|
|
664
|
+
- 前缀以「目录/」开头(目录是 pattern 覆盖范围的祖先)→ True
|
|
665
|
+
宁可放宽(多遍历再逐文件判断),不可误剪。"""
|
|
666
|
+
for pat in patterns:
|
|
667
|
+
prefix = str(pat).lower().split("*")[0]
|
|
668
|
+
if not prefix:
|
|
669
|
+
return True
|
|
670
|
+
if dir_rel.lower().startswith(prefix):
|
|
671
|
+
return True
|
|
672
|
+
if prefix.startswith(dir_rel.lower() + "/"):
|
|
673
|
+
return True
|
|
674
|
+
return False
|
|
675
|
+
def discover(cfg: Config):
|
|
676
|
+
"""遍历项目收集候选文件。返回。
|
|
677
|
+
目录剪枝顺序:include-pattern 覆盖 > 隐藏目录忽略 > 目录黑名单。"""
|
|
678
|
+
root = cfg.root
|
|
679
|
+
out_abs = cfg.output.expanduser().resolve()
|
|
680
|
+
cfg_abs = cfg.config_path.resolve() if cfg.config_path else None
|
|
681
|
+
self_abs = Path(__file__).resolve() if "__file__" in globals() else None
|
|
682
|
+
found, pruned_hidden = [], []
|
|
683
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
684
|
+
kept = []
|
|
685
|
+
for d in sorted(dirnames):
|
|
686
|
+
rel_dir = (Path(dirpath) / d).relative_to(root).as_posix()
|
|
687
|
+
# 强制包含规则优先:可能被 include-pattern 覆盖的目录一律不剪
|
|
688
|
+
if _dir_may_be_included(rel_dir, cfg.include_patterns):
|
|
689
|
+
kept.append(d)
|
|
690
|
+
continue
|
|
691
|
+
# 隐藏目录:以 . 开头且开启忽略 → 整目录剪掉
|
|
692
|
+
if cfg.exclude_hidden and d.startswith("."):
|
|
693
|
+
pruned_hidden.append(rel_dir)
|
|
694
|
+
continue
|
|
695
|
+
# 目录黑名单
|
|
696
|
+
if d.lower() in cfg.exclude_dirs:
|
|
697
|
+
continue
|
|
698
|
+
kept.append(d)
|
|
699
|
+
dirnames[:] = kept
|
|
700
|
+
for fn in sorted(filenames):
|
|
701
|
+
p = Path(dirpath) / fn
|
|
702
|
+
rel = p.relative_to(root)
|
|
703
|
+
rel_posix = rel.as_posix()
|
|
704
|
+
name_l = fn.lower()
|
|
705
|
+
try:
|
|
706
|
+
pa = p.resolve()
|
|
707
|
+
except OSError:
|
|
708
|
+
pa = p
|
|
709
|
+
if pa == out_abs or (cfg_abs and pa == cfg_abs) or (self_abs and pa == self_abs):
|
|
710
|
+
continue
|
|
711
|
+
included_override = _match_any(rel_posix, name_l, cfg.include_patterns)
|
|
712
|
+
if name_l in cfg.exclude_files and not included_override:
|
|
713
|
+
continue
|
|
714
|
+
if _match_any(rel_posix, name_l, cfg.exclude_patterns) and not included_override:
|
|
715
|
+
continue
|
|
716
|
+
ext = p.suffix.lower().lstrip(".")
|
|
717
|
+
if not (cfg.any_text or ext in cfg.exts or name_l in DEFAULT_FILENAMES):
|
|
718
|
+
if not included_override:
|
|
719
|
+
continue
|
|
720
|
+
found.append((p, rel))
|
|
721
|
+
return found, pruned_hidden
|
|
722
|
+
def read_text(p: Path):
|
|
723
|
+
"""自动识别编码读取文本;返回。二进制返回。"""
|
|
724
|
+
try:
|
|
725
|
+
raw = p.read_bytes()
|
|
726
|
+
except OSError as e:
|
|
727
|
+
return None, None, t("read_fail", cls=e.__class__.__name__)
|
|
728
|
+
if b"\x00" in raw:
|
|
729
|
+
return None, None, t("looks_binary")
|
|
730
|
+
for enc in ("utf-8-sig", "utf-8", "gbk", "big5", "latin-1"):
|
|
731
|
+
try:
|
|
732
|
+
return raw.decode(enc), enc, None
|
|
733
|
+
except (UnicodeDecodeError, LookupError):
|
|
734
|
+
continue
|
|
735
|
+
return None, None, t("undecodable")
|
|
736
|
+
def file_priority(p: Path) -> int:
|
|
737
|
+
name_l = p.name.lower()
|
|
738
|
+
if name_l.startswith("readme"):
|
|
739
|
+
return 0
|
|
740
|
+
if name_l in CONFIG_MANIFESTS or name_l in (".gitignore", ".dockerignore", ".editorconfig"):
|
|
741
|
+
return 1
|
|
742
|
+
if p.stem.lower() in ENTRY_STEMS and p.suffix.lower() in ENTRY_EXTS:
|
|
743
|
+
return 2
|
|
744
|
+
if p.stem.lower() in ("config", "settings"):
|
|
745
|
+
return 2
|
|
746
|
+
return 3
|
|
747
|
+
def order_key(item):
|
|
748
|
+
p, rel = item
|
|
749
|
+
return (file_priority(p), rel.as_posix().lower())
|
|
750
|
+
def build_records(cfg: Config, candidates):
|
|
751
|
+
records, skipped = [], []
|
|
752
|
+
total = 0
|
|
753
|
+
budget = int(cfg.max_total_kb * 1024) if cfg.max_total_kb else 0
|
|
754
|
+
for p, rel in candidates:
|
|
755
|
+
try:
|
|
756
|
+
size = p.stat().st_size
|
|
757
|
+
except OSError as e:
|
|
758
|
+
skipped.append((rel.as_posix(), t("skip_unreadable", cls=e.__class__.__name__)))
|
|
759
|
+
continue
|
|
760
|
+
if cfg.max_file_kb and size > cfg.max_file_kb * 1024:
|
|
761
|
+
skipped.append((rel.as_posix(),
|
|
762
|
+
t("skip_too_large", kb=cfg.max_file_kb, size=fmt_size(size))))
|
|
763
|
+
continue
|
|
764
|
+
text, enc, err = read_text(p)
|
|
765
|
+
if text is None:
|
|
766
|
+
skipped.append((rel.as_posix(), t("skip_read_err", err=err)))
|
|
767
|
+
continue
|
|
768
|
+
if budget and records and total + len(text) > budget:
|
|
769
|
+
skipped.append((rel.as_posix(), t("skip_over_budget")))
|
|
770
|
+
continue
|
|
771
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
772
|
+
if not text.endswith("\n"):
|
|
773
|
+
text += "\n"
|
|
774
|
+
orig_lines = len(text.splitlines())
|
|
775
|
+
truncated = False
|
|
776
|
+
if cfg.max_file_lines and orig_lines > cfg.max_file_lines:
|
|
777
|
+
keep = cfg.max_file_lines
|
|
778
|
+
text = "\n".join(text.split("\n")[:keep])
|
|
779
|
+
if not text.endswith("\n"):
|
|
780
|
+
text += "\n"
|
|
781
|
+
text += t("truncated_note", orig=orig_lines, keep=keep)
|
|
782
|
+
truncated = True
|
|
783
|
+
if not text.strip():
|
|
784
|
+
text = t("empty_file")
|
|
785
|
+
records.append(FileRec(
|
|
786
|
+
rel=rel, abspath=p, language=lang_of(p), encoding=enc,
|
|
787
|
+
content=text, lines=len(text.splitlines()), chars=len(text),
|
|
788
|
+
nbytes=size, truncated=truncated, orig_lines=orig_lines,
|
|
789
|
+
))
|
|
790
|
+
total += len(text)
|
|
791
|
+
return records, skipped
|
|
792
|
+
# ─────────────────────────── 渲染(Markdown) ───────────────────────────
|
|
793
|
+
def build_tree(records, root_label: str) -> str:
|
|
794
|
+
tree = {}
|
|
795
|
+
for r in records:
|
|
796
|
+
node = tree
|
|
797
|
+
parts = list(r.rel.parts)
|
|
798
|
+
for part in parts[:-1]:
|
|
799
|
+
node = node.setdefault(part, {})
|
|
800
|
+
node[parts[-1]] = None
|
|
801
|
+
lines = [root_label + "/"]
|
|
802
|
+
def walk(node, prefix):
|
|
803
|
+
items = sorted(node.items(), key=lambda kv: (kv[1] is None, kv[0].lower()))
|
|
804
|
+
for i, (name, child) in enumerate(items):
|
|
805
|
+
last = (i == len(items) - 1)
|
|
806
|
+
lines.append(prefix + ("└── " if last else "├── ") + name + ("/" if child else ""))
|
|
807
|
+
if child:
|
|
808
|
+
walk(child, prefix + (" " if last else "│ "))
|
|
809
|
+
walk(tree, "")
|
|
810
|
+
return "\n".join(lines) + "\n"
|
|
811
|
+
def render(cfg: Config, records, skipped, prompt_text: str, root_name: str,
|
|
812
|
+
part_label: str = "", pruned_hidden=None) -> str:
|
|
813
|
+
n = len(records)
|
|
814
|
+
tot_lines = sum(r.lines for r in records)
|
|
815
|
+
tot_chars = sum(r.chars for r in records)
|
|
816
|
+
tot_tokens = sum(estimate_tokens(r.content) for r in records)
|
|
817
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
818
|
+
pruned_hidden = pruned_hidden or []
|
|
819
|
+
# ── 头部各节:标题/说明/需求/目录树 ──
|
|
820
|
+
head = []
|
|
821
|
+
meta = [
|
|
822
|
+
"- " + t("doc_meta_time", now=now),
|
|
823
|
+
"- " + t("doc_meta_project", root=root_name),
|
|
824
|
+
"- " + (t("doc_meta_files_skipped", n=n, skipped=len(skipped)) if skipped
|
|
825
|
+
else t("doc_meta_files", n=n)),
|
|
826
|
+
"- " + t("doc_meta_lines", lines=f"{tot_lines:,}"),
|
|
827
|
+
"- " + t("doc_meta_size", size=fmt_size(tot_chars)),
|
|
828
|
+
"- " + t("doc_meta_tokens", tokens=f"{tot_tokens:,}"),
|
|
829
|
+
]
|
|
830
|
+
head.append(t("doc_title", root=root_name, label=part_label) + "\n\n" + "\n".join(meta))
|
|
831
|
+
if cfg.ai_header:
|
|
832
|
+
head.append(t("doc_ai_header") + t("ai_notes", root=root_name))
|
|
833
|
+
if prompt_text:
|
|
834
|
+
head.append(t("doc_prompt") + prompt_text)
|
|
835
|
+
if cfg.show_tree:
|
|
836
|
+
tree = build_tree(records, root_name).rstrip("\n")
|
|
837
|
+
tf = fence_for(tree)
|
|
838
|
+
head.append(t("doc_tree") + tf + "\n" + tree + "\n" + tf)
|
|
839
|
+
# ── 文件正文节:### 序号. 路径 + 围栏代码块 ──
|
|
840
|
+
file_secs = []
|
|
841
|
+
for i, r in enumerate(records, 1):
|
|
842
|
+
path = r.rel.as_posix()
|
|
843
|
+
content = r.content
|
|
844
|
+
if cfg.line_numbers:
|
|
845
|
+
ls = content.split("\n")
|
|
846
|
+
if ls and ls[-1] == "":
|
|
847
|
+
ls.pop()
|
|
848
|
+
content = "\n".join(f"{k:>5} | {ln}" for k, ln in enumerate(ls, 1)) + "\n"
|
|
849
|
+
f = fence_for(content)
|
|
850
|
+
info = [f"`{r.language}`", t("doc_lines_unit", n=r.lines)]
|
|
851
|
+
if r.encoding and not r.encoding.startswith("utf"):
|
|
852
|
+
info.append(t("doc_encoding", enc=r.encoding))
|
|
853
|
+
if r.truncated:
|
|
854
|
+
info.append(t("doc_truncated"))
|
|
855
|
+
# 围栏行末尾加内部标记,稍后用于回填索引中的起始行号
|
|
856
|
+
fence_line = f + fence_lang_of(r.rel) + MARK + str(i) + MARK
|
|
857
|
+
file_secs.append(
|
|
858
|
+
f"### {i}. {path}\n\n"
|
|
859
|
+
f"**{i}/{n}** · " + " · ".join(info) + "\n\n" +
|
|
860
|
+
fence_line + "\n" + content + f)
|
|
861
|
+
# ── 索引表(起始行先占位,组装后按标记回填真实行号) ──
|
|
862
|
+
def make_index(starts):
|
|
863
|
+
rows = [t("doc_index_cols"), "|---:|:---|:---|---:|---:|"]
|
|
864
|
+
for i, (r, s) in enumerate(zip(records, starts), 1):
|
|
865
|
+
path = r.rel.as_posix()
|
|
866
|
+
cell = md_table_cell(path)
|
|
867
|
+
anchor = md_slug(f"{i}. {path}")
|
|
868
|
+
rows.append(f"| {i} | [{cell}](#{anchor}) | {r.language} | {r.lines} | {s} |")
|
|
869
|
+
return t("doc_index") + "\n".join(rows)
|
|
870
|
+
# ── 尾部各节:附录 / 结尾 ──
|
|
871
|
+
tail = []
|
|
872
|
+
if file_secs:
|
|
873
|
+
tail.append(t("doc_source") + "\n\n".join(file_secs))
|
|
874
|
+
if skipped:
|
|
875
|
+
items = [t("doc_skip_item", path=md_code_span(rel), reason=reason)
|
|
876
|
+
for rel, reason in skipped[:50]]
|
|
877
|
+
if len(skipped) > 50:
|
|
878
|
+
items.append(t("doc_more_skipped", n=len(skipped) - 50))
|
|
879
|
+
tail.append(t("doc_appendix_skipped") + "\n".join(items))
|
|
880
|
+
if pruned_hidden:
|
|
881
|
+
shown = pruned_hidden[:30]
|
|
882
|
+
items = [t("doc_hidden_item", path=md_code_span(d)) for d in shown]
|
|
883
|
+
if len(pruned_hidden) > 30:
|
|
884
|
+
items.append(t("doc_more_hidden", n=len(pruned_hidden) - 30))
|
|
885
|
+
tail.append(t("doc_appendix_hidden") + "\n".join(items))
|
|
886
|
+
tail.append(t("doc_end", n=n, lines=f"{tot_lines:,}", tokens=f"{tot_tokens:,}",
|
|
887
|
+
tool=TOOL, ver=VERSION, now=now))
|
|
888
|
+
dummy_index = make_index([0] * n) if (cfg.show_index and n) else None
|
|
889
|
+
secs = head + ([dummy_index] if dummy_index else []) + tail
|
|
890
|
+
doc = "\n\n".join(secs) + "\n"
|
|
891
|
+
# ── 回填真实起始行号,并整体移除内部标记(标记+序号一起删除) ──
|
|
892
|
+
if dummy_index:
|
|
893
|
+
starts = [0] * n
|
|
894
|
+
for li, ln in enumerate(doc.split("\n"), 1):
|
|
895
|
+
m = MARK_RE.search(ln)
|
|
896
|
+
if m:
|
|
897
|
+
starts[int(m.group(1)) - 1] = li + 1 # 围栏行的下一行即正文首行
|
|
898
|
+
doc = MARK_RE.sub("", doc)
|
|
899
|
+
if all(s > 0 for s in starts):
|
|
900
|
+
doc = doc.replace(dummy_index, make_index(starts), 1)
|
|
901
|
+
else:
|
|
902
|
+
doc = MARK_RE.sub("", doc)
|
|
903
|
+
return doc
|
|
904
|
+
# ─────────────────────────── 剪贴板 ───────────────────────────
|
|
905
|
+
def _win_clipboard(text: str) -> bool:
|
|
906
|
+
import tempfile
|
|
907
|
+
fd, path = tempfile.mkstemp(suffix=".txt")
|
|
908
|
+
try:
|
|
909
|
+
with os.fdopen(fd, "w", encoding="utf-8-sig") as f:
|
|
910
|
+
f.write(text)
|
|
911
|
+
ps = ("$t = Get-Content -LiteralPath '%s' -Raw -Encoding UTF8; "
|
|
912
|
+
"Set-Clipboard -Value $t" % path.replace("'", "''"))
|
|
913
|
+
subprocess.run(["powershell", "-NoProfile", "-Command", ps],
|
|
914
|
+
check=True, timeout=60, capture_output=True)
|
|
915
|
+
return True
|
|
916
|
+
finally:
|
|
917
|
+
try:
|
|
918
|
+
os.unlink(path)
|
|
919
|
+
except OSError:
|
|
920
|
+
pass
|
|
921
|
+
def copy_clipboard(text: str):
|
|
922
|
+
try:
|
|
923
|
+
import pyperclip # type: ignore
|
|
924
|
+
pyperclip.copy(text)
|
|
925
|
+
return True, "pyperclip"
|
|
926
|
+
except Exception:
|
|
927
|
+
pass
|
|
928
|
+
try:
|
|
929
|
+
if sys.platform == "win32":
|
|
930
|
+
try:
|
|
931
|
+
if _win_clipboard(text):
|
|
932
|
+
return True, "PowerShell"
|
|
933
|
+
except Exception:
|
|
934
|
+
subprocess.run(["clip"], input=text.encode("utf-16-le"),
|
|
935
|
+
check=True, capture_output=True)
|
|
936
|
+
return True, "clip"
|
|
937
|
+
elif sys.platform == "darwin":
|
|
938
|
+
subprocess.run(["pbcopy"], input=text.encode("utf-8"),
|
|
939
|
+
check=True, capture_output=True)
|
|
940
|
+
return True, "pbcopy"
|
|
941
|
+
else:
|
|
942
|
+
for cmd in (["wl-copy"], ["xclip", "-selection", "clipboard"],
|
|
943
|
+
["xsel", "--clipboard", "--input"]):
|
|
944
|
+
if shutil.which(cmd[0]):
|
|
945
|
+
subprocess.run(cmd, input=text.encode("utf-8"),
|
|
946
|
+
check=True, capture_output=True)
|
|
947
|
+
return True, cmd[0]
|
|
948
|
+
except Exception:
|
|
949
|
+
pass
|
|
950
|
+
return False, ""
|
|
951
|
+
# ─────────────────────────── 报告输出 ───────────────────────────
|
|
952
|
+
def print_summary(out_path: Path, records, skipped, text: str, pruned_hidden=None):
|
|
953
|
+
tot_lines = sum(r.lines for r in records)
|
|
954
|
+
tot_tokens = sum(estimate_tokens(r.content) for r in records)
|
|
955
|
+
size = len(text.encode("utf-8"))
|
|
956
|
+
pruned_hidden = pruned_hidden or []
|
|
957
|
+
cprint()
|
|
958
|
+
cprint(t("sum_generated", path=out_path))
|
|
959
|
+
if skipped:
|
|
960
|
+
cprint(t("sum_files_skipped", n=len(records), skipped=len(skipped)))
|
|
961
|
+
else:
|
|
962
|
+
cprint(t("sum_files", n=len(records)))
|
|
963
|
+
if pruned_hidden:
|
|
964
|
+
cprint(t("sum_hidden", n=len(pruned_hidden)))
|
|
965
|
+
cprint(t("sum_lines", lines=f"{tot_lines:,}"))
|
|
966
|
+
cprint(t("sum_size", size=fmt_size(size)))
|
|
967
|
+
cprint(t("sum_tokens", tokens=f"{tot_tokens:,}", hint=token_hint(tot_tokens)))
|
|
968
|
+
cprint()
|
|
969
|
+
cprint(t("sum_tip1"))
|
|
970
|
+
cprint(t("sum_tip2"))
|
|
971
|
+
def dry_run_report(cfg: Config, records, skipped, pruned_hidden=None):
|
|
972
|
+
tot = sum(estimate_tokens(r.content) for r in records)
|
|
973
|
+
pruned_hidden = pruned_hidden or []
|
|
974
|
+
cprint(t("dry_preview", n=len(records),
|
|
975
|
+
lines=f"{sum(r.lines for r in records):,}", tokens=f"{tot:,}"))
|
|
976
|
+
if cfg.show_tree:
|
|
977
|
+
cprint()
|
|
978
|
+
cprint(build_tree(records, cfg.root.name).rstrip("\n"))
|
|
979
|
+
cprint()
|
|
980
|
+
for i, r in enumerate(records, 1):
|
|
981
|
+
flag = t("dry_truncated_flag") if r.truncated else ""
|
|
982
|
+
cprint(t("dry_file_item", i=f"{i:>3}", path=r.rel.as_posix(),
|
|
983
|
+
lang=r.language, lines=r.lines, size=fmt_size(r.nbytes), flag=flag))
|
|
984
|
+
if skipped:
|
|
985
|
+
cprint(t("dry_skipped_head", n=len(skipped)))
|
|
986
|
+
for rel, reason in skipped[:20]:
|
|
987
|
+
cprint(t("dry_skip_item", rel=rel, reason=reason))
|
|
988
|
+
if len(skipped) > 20:
|
|
989
|
+
cprint(t("dry_more", n=len(skipped) - 20))
|
|
990
|
+
if pruned_hidden:
|
|
991
|
+
cprint(t("dry_hidden_head", n=len(pruned_hidden)))
|
|
992
|
+
for d in pruned_hidden[:20]:
|
|
993
|
+
cprint(f" - {d}/")
|
|
994
|
+
if len(pruned_hidden) > 20:
|
|
995
|
+
cprint(t("dry_more", n=len(pruned_hidden) - 20))
|
|
996
|
+
cprint(t("dry_tokens", tokens=f"{tot:,}", hint=token_hint(tot)))
|
|
997
|
+
cprint(t("dry_dryrun"))
|
|
998
|
+
# ─────────────────────────── CLI ───────────────────────────
|
|
999
|
+
def parse_args(argv=None):
|
|
1000
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
1001
|
+
# 预扫描 --lang/--language:让 --help 也按所选语言渲染
|
|
1002
|
+
pre = None
|
|
1003
|
+
for i, a in enumerate(argv):
|
|
1004
|
+
if a in ("--lang", "--language") and i + 1 < len(argv):
|
|
1005
|
+
pre = argv[i + 1]
|
|
1006
|
+
elif a.startswith("--lang=") or a.startswith("--language="):
|
|
1007
|
+
pre = a.split("=", 1)[1]
|
|
1008
|
+
set_lang(pre) # None → 按系统探测
|
|
1009
|
+
p = argparse.ArgumentParser(
|
|
1010
|
+
description=t("cli_desc"),
|
|
1011
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1012
|
+
epilog=t("cli_epilog"),
|
|
1013
|
+
add_help=False) # 关闭 argparse 自动注册的 -h/--help,改为下方显式声明
|
|
1014
|
+
p.add_argument("-h", "--help", action="help", default=argparse.SUPPRESS,
|
|
1015
|
+
help=t("arg_help"))
|
|
1016
|
+
p.add_argument("root", nargs="?", default=".", help=t("arg_root"))
|
|
1017
|
+
p.add_argument("-o", "--output", default=None, help=t("arg_output", out=DEFAULT_OUTPUT))
|
|
1018
|
+
p.add_argument("--ext", nargs="+", metavar="EXT", help=t("arg_ext"))
|
|
1019
|
+
p.add_argument("--only-ext", nargs="+", metavar="EXT", help=t("arg_only_ext"))
|
|
1020
|
+
p.add_argument("--any-text", action="store_true", help=t("arg_any_text"))
|
|
1021
|
+
p.add_argument("--include-hidden", action="store_true", help=t("arg_include_hidden"))
|
|
1022
|
+
p.add_argument("--exclude-dir", nargs="+", metavar="DIR", help=t("arg_exclude_dir"))
|
|
1023
|
+
p.add_argument("--exclude-file", nargs="+", metavar="NAME", help=t("arg_exclude_file"))
|
|
1024
|
+
p.add_argument("--exclude-pattern", nargs="+", metavar="PAT", help=t("arg_exclude_pattern"))
|
|
1025
|
+
p.add_argument("--include-pattern", nargs="+", metavar="PAT", help=t("arg_include_pattern"))
|
|
1026
|
+
p.add_argument("--lang", "--language", dest="lang",
|
|
1027
|
+
choices=("auto",) + SUPPORTED_LANGS, default=None, help=t("arg_lang"))
|
|
1028
|
+
p.add_argument("--line-numbers", action="store_true", help=t("arg_line_numbers"))
|
|
1029
|
+
p.add_argument("--max-file-lines", type=int, default=None, metavar="N",
|
|
1030
|
+
help=t("arg_max_file_lines"))
|
|
1031
|
+
p.add_argument("--max-file-kb", type=float, default=None, metavar="KB",
|
|
1032
|
+
help=t("arg_max_file_kb"))
|
|
1033
|
+
p.add_argument("--max-total-kb", type=float, default=None, metavar="KB",
|
|
1034
|
+
help=t("arg_max_total_kb"))
|
|
1035
|
+
p.add_argument("--split-tokens", type=int, default=None, metavar="N",
|
|
1036
|
+
help=t("arg_split_tokens"))
|
|
1037
|
+
p.add_argument("--no-tree", action="store_true", help=t("arg_no_tree"))
|
|
1038
|
+
p.add_argument("--no-index", action="store_true", help=t("arg_no_index"))
|
|
1039
|
+
p.add_argument("--no-ai-header", action="store_true", help=t("arg_no_ai_header"))
|
|
1040
|
+
p.add_argument("--no-smart-order", action="store_true", help=t("arg_no_smart_order"))
|
|
1041
|
+
p.add_argument("--prompt", default=None, help=t("arg_prompt"))
|
|
1042
|
+
p.add_argument("--prompt-file", default=None, help=t("arg_prompt_file"))
|
|
1043
|
+
p.add_argument("--clip", action="store_true", help=t("arg_clip"))
|
|
1044
|
+
p.add_argument("--stdout", action="store_true", help=t("arg_stdout"))
|
|
1045
|
+
p.add_argument("--dry-run", action="store_true", help=t("arg_dry_run"))
|
|
1046
|
+
p.add_argument("--config", default=None, help=t("arg_config", cfg=CONFIG_FILENAME))
|
|
1047
|
+
p.add_argument("--no-config", action="store_true", help=t("arg_no_config"))
|
|
1048
|
+
p.add_argument("--init-config", action="store_true",
|
|
1049
|
+
help=t("arg_init_config", cfg=CONFIG_FILENAME))
|
|
1050
|
+
p.add_argument("--quiet", action="store_true", help=t("arg_quiet"))
|
|
1051
|
+
p.add_argument("--version", action="store_true", help=t("arg_version"))
|
|
1052
|
+
return p.parse_args(argv)
|
|
1053
|
+
def build_config(root: Path, args, data: dict, cfg_path) -> Config:
|
|
1054
|
+
def v(key, cli, default):
|
|
1055
|
+
if cli is not None:
|
|
1056
|
+
return cli
|
|
1057
|
+
if key in data and data[key] is not None:
|
|
1058
|
+
return data[key]
|
|
1059
|
+
return default
|
|
1060
|
+
def flag(key, no_cli, default):
|
|
1061
|
+
if no_cli:
|
|
1062
|
+
return False
|
|
1063
|
+
return bool(data.get(key, default))
|
|
1064
|
+
def pos_flag(key, cli):
|
|
1065
|
+
return bool(cli) or bool(data.get(key, False))
|
|
1066
|
+
exts = set(DEFAULT_EXTS)
|
|
1067
|
+
cfg_exts = data.get("exts")
|
|
1068
|
+
if isinstance(cfg_exts, list) and cfg_exts:
|
|
1069
|
+
exts = {normalize_ext(e) for e in cfg_exts}
|
|
1070
|
+
if args.only_ext:
|
|
1071
|
+
exts = {normalize_ext(e) for e in args.only_ext}
|
|
1072
|
+
elif args.ext:
|
|
1073
|
+
exts |= {normalize_ext(e) for e in args.ext}
|
|
1074
|
+
def merge_set(defaults, key, cli_val):
|
|
1075
|
+
s = set(defaults)
|
|
1076
|
+
cv = data.get(key)
|
|
1077
|
+
if isinstance(cv, list):
|
|
1078
|
+
s |= {str(x).lower() for x in cv}
|
|
1079
|
+
if cli_val:
|
|
1080
|
+
s |= {str(x).lower() for x in cli_val}
|
|
1081
|
+
return s
|
|
1082
|
+
def merge_list(defaults, key, cli_val):
|
|
1083
|
+
out = list(defaults)
|
|
1084
|
+
cv = data.get(key)
|
|
1085
|
+
if isinstance(cv, list):
|
|
1086
|
+
out += [str(x) for x in cv]
|
|
1087
|
+
if cli_val:
|
|
1088
|
+
out += [str(x) for x in cli_val]
|
|
1089
|
+
return out
|
|
1090
|
+
# 隐藏目录忽略:默认 True;命令行 --include-hidden 或配置 exclude_hidden=false 可关闭
|
|
1091
|
+
exclude_hidden = True
|
|
1092
|
+
if args.include_hidden:
|
|
1093
|
+
exclude_hidden = False
|
|
1094
|
+
elif "exclude_hidden" in data and data["exclude_hidden"] is not None:
|
|
1095
|
+
exclude_hidden = bool(data["exclude_hidden"])
|
|
1096
|
+
return Config(
|
|
1097
|
+
root=root,
|
|
1098
|
+
output=Path(v("output", args.output, DEFAULT_OUTPUT)),
|
|
1099
|
+
exts=exts,
|
|
1100
|
+
any_text=pos_flag("any_text", args.any_text),
|
|
1101
|
+
exclude_hidden=exclude_hidden,
|
|
1102
|
+
exclude_dirs=merge_set(DEFAULT_EXCLUDE_DIRS, "exclude_dirs", args.exclude_dir),
|
|
1103
|
+
exclude_files=merge_set(DEFAULT_EXCLUDE_FILES, "exclude_files", args.exclude_file),
|
|
1104
|
+
exclude_patterns=merge_list(DEFAULT_EXCLUDE_PATTERNS, "exclude_patterns", args.exclude_pattern),
|
|
1105
|
+
include_patterns=merge_list([], "include_patterns", args.include_pattern),
|
|
1106
|
+
line_numbers=pos_flag("line_numbers", args.line_numbers),
|
|
1107
|
+
max_file_lines=int(v("max_file_lines", args.max_file_lines, 0) or 0),
|
|
1108
|
+
max_file_kb=float(v("max_file_kb", args.max_file_kb, 512) or 0),
|
|
1109
|
+
max_total_kb=float(v("max_total_kb", args.max_total_kb, 0) or 0),
|
|
1110
|
+
split_tokens=int(v("split_tokens", args.split_tokens, 0) or 0),
|
|
1111
|
+
show_tree=flag("show_tree", args.no_tree, True),
|
|
1112
|
+
show_index=flag("show_index", args.no_index, True),
|
|
1113
|
+
ai_header=flag("ai_header", args.no_ai_header, True),
|
|
1114
|
+
smart_order=flag("smart_order", args.no_smart_order, True),
|
|
1115
|
+
clip=pos_flag("clip", args.clip),
|
|
1116
|
+
config_path=cfg_path,
|
|
1117
|
+
)
|
|
1118
|
+
def load_prompt(args) -> str:
|
|
1119
|
+
if args.prompt:
|
|
1120
|
+
return args.prompt.strip()
|
|
1121
|
+
if args.prompt_file:
|
|
1122
|
+
try:
|
|
1123
|
+
return Path(args.prompt_file).read_text(encoding="utf-8").strip()
|
|
1124
|
+
except Exception as e:
|
|
1125
|
+
cprint(t("warn_prompt_file", err=e))
|
|
1126
|
+
return ""
|
|
1127
|
+
# ─────────────────────────── 主流程 ───────────────────────────
|
|
1128
|
+
def main(argv=None):
|
|
1129
|
+
args = parse_args(argv)
|
|
1130
|
+
if args.version:
|
|
1131
|
+
cprint(f"{TOOL} v{VERSION}")
|
|
1132
|
+
return 0
|
|
1133
|
+
root = Path(args.root).expanduser().resolve()
|
|
1134
|
+
quiet = args.quiet
|
|
1135
|
+
cfg_path = (Path(args.config).expanduser().resolve()
|
|
1136
|
+
if args.config else root / CONFIG_FILENAME)
|
|
1137
|
+
# ── 1. 先静默读取配置文件(界面语言可能写在里面),暂存加载结果 ──
|
|
1138
|
+
data, load_state = {}, None # None / ("ok",) / ("bad_root",) / ("error", exc)
|
|
1139
|
+
if not args.no_config and cfg_path.is_file():
|
|
1140
|
+
try:
|
|
1141
|
+
loaded = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
1142
|
+
if not isinstance(loaded, dict):
|
|
1143
|
+
load_state = ("bad_root",)
|
|
1144
|
+
else:
|
|
1145
|
+
data, load_state = loaded, ("ok",)
|
|
1146
|
+
except Exception as e:
|
|
1147
|
+
load_state = ("error", e)
|
|
1148
|
+
# ── 2. 解析界面语言:--lang 参数 > 配置文件 language 字段 > 系统探测 ──
|
|
1149
|
+
set_lang(args.lang or data.get("language") or "auto")
|
|
1150
|
+
if not quiet and load_state:
|
|
1151
|
+
if load_state[0] == "ok":
|
|
1152
|
+
cprint(t("info_config_loaded", path=cfg_path))
|
|
1153
|
+
elif load_state[0] == "bad_root":
|
|
1154
|
+
cprint(t("warn_config_parse", err=t("err_config_root")))
|
|
1155
|
+
else:
|
|
1156
|
+
cprint(t("warn_config_parse", err=load_state[1]))
|
|
1157
|
+
if not root.is_dir():
|
|
1158
|
+
cprint(t("err_root_not_dir", root=root))
|
|
1159
|
+
return 1
|
|
1160
|
+
if args.init_config:
|
|
1161
|
+
if cfg_path.exists():
|
|
1162
|
+
cprint(t("err_config_exists", path=cfg_path))
|
|
1163
|
+
return 1
|
|
1164
|
+
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1165
|
+
cfg_path.write_text(json.dumps(CONFIG_TEMPLATE, ensure_ascii=False, indent=2) + "\n",
|
|
1166
|
+
encoding="utf-8")
|
|
1167
|
+
cprint(t("ok_config_created", path=cfg_path))
|
|
1168
|
+
cprint(t("config_hint"))
|
|
1169
|
+
return 0
|
|
1170
|
+
cfg = build_config(root, args, data, cfg_path)
|
|
1171
|
+
candidates, pruned_hidden = discover(cfg)
|
|
1172
|
+
if not candidates:
|
|
1173
|
+
cprint(t("err_no_files"))
|
|
1174
|
+
return 1
|
|
1175
|
+
if cfg.smart_order:
|
|
1176
|
+
candidates.sort(key=order_key)
|
|
1177
|
+
records, skipped = build_records(cfg, candidates)
|
|
1178
|
+
if not records:
|
|
1179
|
+
cprint(t("err_all_skipped"))
|
|
1180
|
+
return 1
|
|
1181
|
+
prompt_text = load_prompt(args)
|
|
1182
|
+
if args.dry_run:
|
|
1183
|
+
dry_run_report(cfg, records, skipped, pruned_hidden)
|
|
1184
|
+
return 0
|
|
1185
|
+
# ── 分卷模式 ──
|
|
1186
|
+
if cfg.split_tokens and not args.stdout:
|
|
1187
|
+
chunks, cur_chunk, cur_tok = [], [], 0
|
|
1188
|
+
for r in records:
|
|
1189
|
+
tk = estimate_tokens(r.content) # 注意:勿命名为 t,避免遮蔽翻译函数
|
|
1190
|
+
if cur_chunk and cur_tok + tk > cfg.split_tokens:
|
|
1191
|
+
chunks.append((cur_chunk, cur_tok))
|
|
1192
|
+
cur_chunk, cur_tok = [], 0
|
|
1193
|
+
cur_chunk.append(r)
|
|
1194
|
+
cur_tok += tk
|
|
1195
|
+
if cur_chunk:
|
|
1196
|
+
chunks.append((cur_chunk, cur_tok))
|
|
1197
|
+
if len(chunks) > 1:
|
|
1198
|
+
base = cfg.output.expanduser()
|
|
1199
|
+
base.parent.mkdir(parents=True, exist_ok=True)
|
|
1200
|
+
stem, suf = base.stem, base.suffix or ".md"
|
|
1201
|
+
for i, (chunk, tok) in enumerate(chunks, 1):
|
|
1202
|
+
label = t("part_label", i=i, n=len(chunks))
|
|
1203
|
+
sk = skipped if i == len(chunks) else []
|
|
1204
|
+
ph = pruned_hidden if i == len(chunks) else []
|
|
1205
|
+
txt = render(cfg, chunk, sk, prompt_text, root.name,
|
|
1206
|
+
part_label=label, pruned_hidden=ph)
|
|
1207
|
+
p = base.with_name(f"{stem}.part{i}{suf}")
|
|
1208
|
+
with open(p, "w", encoding="utf-8", newline="\n") as fh:
|
|
1209
|
+
fh.write(txt)
|
|
1210
|
+
if not quiet:
|
|
1211
|
+
cprint(t("ok_part_generated", name=p.name, files=len(chunk),
|
|
1212
|
+
tokens=f"{tok:,}",
|
|
1213
|
+
size=fmt_size(len(txt.encode("utf-8")))))
|
|
1214
|
+
if not quiet:
|
|
1215
|
+
cprint(t("split_hint", n=cfg.split_tokens, total=len(chunks)))
|
|
1216
|
+
return 0
|
|
1217
|
+
text = render(cfg, records, skipped, prompt_text, root.name, pruned_hidden=pruned_hidden)
|
|
1218
|
+
if args.stdout:
|
|
1219
|
+
sys.stdout.write(text)
|
|
1220
|
+
return 0
|
|
1221
|
+
out = cfg.output.expanduser()
|
|
1222
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
1223
|
+
with open(out, "w", encoding="utf-8", newline="\n") as fh:
|
|
1224
|
+
fh.write(text)
|
|
1225
|
+
if quiet:
|
|
1226
|
+
cprint(str(out))
|
|
1227
|
+
else:
|
|
1228
|
+
print_summary(out, records, skipped, text, pruned_hidden)
|
|
1229
|
+
if cfg.clip:
|
|
1230
|
+
ok, how = copy_clipboard(text)
|
|
1231
|
+
if ok:
|
|
1232
|
+
cprint(t("ok_clipboard", how=how))
|
|
1233
|
+
else:
|
|
1234
|
+
cprint(t("err_clipboard"))
|
|
1235
|
+
return 0
|
|
1236
|
+
|
|
1237
|
+
def cli() -> None:
|
|
1238
|
+
"""命令行入口:pip/pipx 安装后由 `proj2md` 可执行文件调用。"""
|
|
1239
|
+
try:
|
|
1240
|
+
sys.exit(main())
|
|
1241
|
+
except KeyboardInterrupt:
|
|
1242
|
+
cprint(t("cancelled"))
|
|
1243
|
+
sys.exit(130)
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
if __name__ == "__main__": # 保留:直接 python proj2md.py 依然可用
|
|
1247
|
+
cli()
|