feat(doc): 新增文档转换与规则化归档 Skill
This commit is contained in:
@@ -69,7 +69,7 @@ plugins/<plugin>/skills/<skill>/
|
||||
- 迁移采用“分析能力 → 编写中性规格 → 脱离原文独立实现”的方式,不做目录复制、批量替词或近义改写。
|
||||
- 不复制来源 Skill 的正文、脚本、提示词、模板、示例、规则库、注释或独特文档结构。
|
||||
- 不写入公司名称、产品名称、内部域名、内部包名、项目名、人员信息、业务规则或环境路径。
|
||||
- 公司框架 API、组件契约、审批流程、版本矩阵和内部消息格式默认排除。
|
||||
- 公司框架 API、组件契约、审批流程、版本矩阵和内部消息格式不得直接迁移;评估时应先抽象其通用问题,并优先设计基于项目配置、公开标准或用户显式输入的中性平替。只有不存在独立通用价值、无法安全替代或与 Codex 场景不成立时才排除。
|
||||
- 通用技术规范必须根据公开标准或官方资料重新设计;需要引用时记录来源和许可证。
|
||||
- 来源文件只用于人工分析和哈希追踪,不得进入 `plugins/` 发布目录。
|
||||
- 上游变化只触发复核,不自动覆盖已迁移 Skill。
|
||||
@@ -105,12 +105,12 @@ plugins/<plugin>/skills/<skill>/
|
||||
| 来源标识 | 使用本地迁移标识,不在发布目录写入公司品牌 |
|
||||
| 来源 Skill | 原始名称和相对路径 |
|
||||
| 当前作用 | 根据源码确认的能力、输入、输出和关键边界 |
|
||||
| 迁移类型 | 新增、更新、合并、公开资料重建或排除 |
|
||||
| 迁移类型 | 新增、更新、合并、中性平替、公开资料重建或排除 |
|
||||
| 风险 | 公司专属知识、内部依赖、重复能力和兼容性问题 |
|
||||
|
||||
来源作用必须有文件依据;无法从静态内容确认的运行行为应明确标记为未验证。
|
||||
|
||||
同一目标能力存在多个来源时,应在一个迁移方案中共同评估,明确合并、取舍或排除关系,不按来源机械创建多个目标 Skill。
|
||||
同一目标能力存在多个来源时,应在一个迁移方案中共同评估,明确合并、取舍、中性平替或排除关系,不按来源机械创建多个目标 Skill。来源含有大量专有规则时,不得只罗列删除项;必须说明通用问题如何由配置、标准接口、用户输入或项目资料替代。
|
||||
|
||||
### 3. 给出目标名称、作用和组织结构
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ CraftKit 是一组面向 Codex 插件市场的中性 Skill 工具。项目从通
|
||||
| 插件 | 用途 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| `dev` | 软件设计、编码、审查与测试 | 已初始化,暂无 Skill |
|
||||
| `doc` | 文档转换、整理与写作 | 已迁移 `format-md`、`docx-to-md` |
|
||||
| `doc` | 文档转换、整理与写作 | 已迁移 `format-md`、`docx-to-md`、`md-to-docx`、`xlsx-to-md`、`archive` |
|
||||
| `git` | 分支、提交、变更提取与集成 | 已迁移 `commit-msg`、`branch` |
|
||||
| `knowledge` | 项目初始化、交接、复盘与经验 | 已迁移 `handoff`、`init` |
|
||||
| `skill` | 项目规范及 Skill 创建、迁移与维护 | 已迁移 `guidance` |
|
||||
@@ -37,6 +37,7 @@ CraftKit/
|
||||
## 开发约束
|
||||
|
||||
- Skill 必须独立重写,不复制公司插件的正文、脚本、模板、示例或规则库。
|
||||
- 专有能力应先抽象为可配置、基于公开标准或用户显式输入的中性平替;不能仅因专有内容较多就整体排除。
|
||||
- 发布内容不得出现公司名称、产品名称、内部域名、内部包名、项目名或人员信息。
|
||||
- 每个 Skill 文件夹名必须与 `SKILL.md` frontmatter 的 `name` 一致。
|
||||
- 新增或修改插件后必须执行 Skill 校验、插件校验和去公司化扫描。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
将两个本地来源中的通用能力整理为 CraftKit Skill。迁移结果必须适用于 Codex 插件市场,并与来源项目的品牌、内部框架、业务知识和运行环境解耦。
|
||||
|
||||
迁移不追求来源 Skill 与目标 Skill 一一对应。重复能力应合并,公司专属能力应排除,目标数量以职责清晰和实际复用价值为准。
|
||||
迁移不追求来源 Skill 与目标 Skill 一一对应。重复能力应合并;公司专属实现不得迁移,但应优先将其解决的通用问题重建为中性平替,确无独立价值或无法安全替代时才排除。目标数量以职责清晰和实际复用价值为准。
|
||||
|
||||
## 2. 迁移路径
|
||||
|
||||
@@ -60,9 +60,9 @@
|
||||
5. `knowledge/handoff`:模板化文档与项目上下文样本,已完成。
|
||||
6. `skill/guidance`:大型参考资料、索引和渐进式加载样本,已完成;只检索项目资料和基于公开一级资料独立重建的中性公共基线。
|
||||
7. `knowledge/init`:成熟项目与空项目双模式初始化、参考项目提炼和共享/本地信息边界样本,已完成。
|
||||
8. `skill/migrate`:脚本、检查、同步和状态追踪组成的复合工作流样本。
|
||||
8. `skill/migrate`:不迁移。来源能力用于把 Claude Code Skill 转换为 Codex Skill;CraftKit 自始按 Codex 规范开发,不存在平台转换需求。来源扫描、目标检查和状态追踪继续由 `migration/scripts/` 作为仓库维护设施承担。
|
||||
|
||||
完成门槛:每种样本均通过对应验证,并形成可复用的命名、目录、独立实现、测试、扫描和状态同步规则。出现未覆盖的新结构或权限类型时,应补充样本,不直接扩批。
|
||||
完成门槛:适用样本均通过对应验证,不适用样本记录排除依据,并形成可复用的命名、目录、独立实现、测试、扫描和状态同步规则。出现未覆盖的新结构或权限类型时,应补充样本,不直接扩批。当前特殊样本阶段已完成,可以进入同质批量迁移。
|
||||
|
||||
### 第 2 阶段:同质批量迁移
|
||||
|
||||
@@ -78,9 +78,9 @@
|
||||
所属插件:`doc`
|
||||
|
||||
1. `docx-to-md`
|
||||
2. `markdown-to-docx`
|
||||
3. `excel-to-markdown`
|
||||
4. `archive-docs`
|
||||
2. `md-to-docx`(已完成)
|
||||
3. `xlsx-to-md`(已完成)
|
||||
4. `archive`(已完成,以可配置规则平替固定目录、业务文件名、专有章节拆分和内部系统校验)
|
||||
|
||||
重点验证图片、表格、合并单元格、编码、覆盖策略和路径安全。脚本及测试数据必须独立创建。
|
||||
|
||||
@@ -230,5 +230,6 @@ plan-change
|
||||
- [x] 迁移并验证只读 Git 特殊样本 `git/commit-msg`。
|
||||
- [x] 迁移并验证有副作用 Git 特殊样本 `git/branch`。
|
||||
- [x] 迁移并验证模板化交接特殊样本 `knowledge/handoff`。
|
||||
- [ ] 完成其余特殊样本并总结批量迁移规则。
|
||||
- [ ] 按插件和风险类型推进同质批量迁移。
|
||||
- [x] 完成其余特殊样本并总结批量迁移规则。
|
||||
- [ ] 按插件和风险类型继续推进同质批量迁移。
|
||||
- [x] 完成首个同质批量:`md-to-docx`、`xlsx-to-md`、`archive`。
|
||||
|
||||
@@ -63,12 +63,17 @@
|
||||
"source-a:4efb6f9dd0f39dff": {
|
||||
"sourcePathHash": "4efb6f9dd0f39dff13339e62ff24b672552efcbcedb007e9ea86fe41950adbb0",
|
||||
"sourceSha256": "a914823bdf50d9e5c8ee70d6363c5c0e7aea341ed390587a9285c70a7047558e",
|
||||
"status": "pending"
|
||||
"status": "migrated",
|
||||
"target": "plugins/doc/skills/xlsx-to-md",
|
||||
"targetVersion": "0.1.0",
|
||||
"reviewedAt": "2026-08-25"
|
||||
},
|
||||
"source-a:32e7cfc21d252985": {
|
||||
"sourcePathHash": "32e7cfc21d25298559bffbe7f0918f0f6e7d2aac29e3eca39d1f43d8226cf934",
|
||||
"sourceSha256": "0f0486e3f25279a1a1cb8cecfef4dc01830471b5d31b52a9d17a425919997bc2",
|
||||
"status": "pending"
|
||||
"status": "excluded",
|
||||
"reason": "CraftKit 原生面向 Codex,不需要 Claude Code 到 Codex 的平台转换能力",
|
||||
"reviewedAt": "2026-08-25"
|
||||
},
|
||||
"source-a:bc91b57fa2a50049": {
|
||||
"sourcePathHash": "bc91b57fa2a500498b31b0f1a87dfa8ab83c32e4b361b4ea1614ea6721234b7c",
|
||||
@@ -162,7 +167,10 @@
|
||||
"source-b:b127c47139270a7f": {
|
||||
"sourcePathHash": "b127c47139270a7f9157e18ad6a0c975b99caf07b9a0874daeac86d9a99ce48c",
|
||||
"sourceSha256": "0214b80071f07beaff4f5837b983546ff71dbaa65bc29e34a5a23c9d946b683b",
|
||||
"status": "pending"
|
||||
"status": "migrated",
|
||||
"target": "plugins/doc/skills/md-to-docx",
|
||||
"targetVersion": "0.1.0",
|
||||
"reviewedAt": "2026-08-25"
|
||||
},
|
||||
"source-b:34ab291ce3cfed2e": {
|
||||
"sourcePathHash": "34ab291ce3cfed2e69cc6f08309b1a41142be91235b9a573daf98639b4a001cc",
|
||||
@@ -319,7 +327,10 @@
|
||||
"source-b:3763e17cff825df6": {
|
||||
"sourcePathHash": "3763e17cff825df6920f57d84736093082014f2a0316d9e84ee18ac8078ff6e5",
|
||||
"sourceSha256": "bd2e4ae7f8a9bded1f7a983289fb49556f30cd5d6c6382500dd8c464fdd66e02",
|
||||
"status": "pending"
|
||||
"status": "migrated",
|
||||
"target": "plugins/doc/skills/archive",
|
||||
"targetVersion": "0.1.0",
|
||||
"reviewedAt": "2026-08-25"
|
||||
},
|
||||
"source-b:9e3e75840a46af96": {
|
||||
"sourcePathHash": "9e3e75840a46af96c33a1885f192813239b511eb972a4962b30b76de7392c88c",
|
||||
@@ -340,7 +351,9 @@
|
||||
"source-b:988b200efd0b8be0": {
|
||||
"sourcePathHash": "988b200efd0b8be024060e0785da2f864bf7ee30d7c7b434fde7ab1e4cdcc9d8",
|
||||
"sourceSha256": "e7fa073d592167c07b06698d664192baafb126afa1a6bfdc50a056e1ea13e56f",
|
||||
"status": "pending"
|
||||
"status": "excluded",
|
||||
"reason": "CraftKit 原生面向 Codex,不需要 Claude Code 到 Codex 的平台转换能力",
|
||||
"reviewedAt": "2026-08-25"
|
||||
},
|
||||
"source-b:8a7cdf719d8f8a3e": {
|
||||
"sourcePathHash": "8a7cdf719d8f8a3e0200781a82062cc5a839e4d2c2fd400982771968245d62dd",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""验证文档批次三个独立实现的核心行为和安全边界。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from openpyxl import Workbook
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MD_SCRIPT = ROOT / "plugins/doc/skills/md-to-docx/scripts/convert.py"
|
||||
XLSX_SCRIPT = ROOT / "plugins/doc/skills/xlsx-to-md/scripts/convert.py"
|
||||
ARCHIVE_SCRIPT = ROOT / "plugins/doc/skills/archive/scripts/archive.py"
|
||||
|
||||
|
||||
class DocumentBatchTests(unittest.TestCase):
|
||||
"""使用临时目录执行真实转换,不依赖仓库外测试材料。"""
|
||||
|
||||
def run_script(self, script: Path, *args: object) -> subprocess.CompletedProcess[str]:
|
||||
"""使用当前受控 Python 运行时执行目标脚本。"""
|
||||
|
||||
return subprocess.run(
|
||||
[str(Path(__import__("sys").executable)), str(script), *(str(value) for value in args)],
|
||||
capture_output=True, text=True, encoding="utf-8", check=False,
|
||||
)
|
||||
|
||||
def test_markdown_to_docx_preserves_common_blocks(self) -> None:
|
||||
"""标题、列表、表格和代码块应生成可重新打开的 DOCX。"""
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
folder = Path(temp); source = folder / "sample.md"; output = folder / "sample.docx"
|
||||
source.write_text("# 标题\n\n- 项目\n\n| 名称 | 值 |\n| --- | --- |\n| A | 1 |\n\n```py\nprint('ok')\n```\n", encoding="utf-8")
|
||||
result = self.run_script(MD_SCRIPT, source)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
document = Document(output)
|
||||
self.assertEqual("标题", document.paragraphs[0].text)
|
||||
self.assertEqual(1, len(document.tables))
|
||||
self.assertNotEqual(0, self.run_script(MD_SCRIPT, source).returncode)
|
||||
|
||||
def test_xlsx_to_markdown_handles_merges_and_escaping(self) -> None:
|
||||
"""合并区域应填充值,表格特殊字符应安全转义。"""
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
folder = Path(temp); source = folder / "sample.xlsx"
|
||||
workbook = Workbook(); sheet = workbook.active; sheet.title = "数据"
|
||||
sheet.append(["名称", "值"]); sheet.append(["A|B", "=1+1"])
|
||||
sheet.merge_cells("A3:B3"); sheet["A3"] = "合并"
|
||||
workbook.create_sheet("空表"); workbook.save(source)
|
||||
result = self.run_script(XLSX_SCRIPT, source)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
markdown = source.with_suffix(".md").read_text(encoding="utf-8")
|
||||
self.assertIn(r"A\|B", markdown)
|
||||
self.assertIn("| 合并 | 合并 |", markdown)
|
||||
self.assertIn("_空工作表_", markdown)
|
||||
|
||||
def test_archive_is_dry_run_first_and_keeps_source(self) -> None:
|
||||
"""预演不得写目标,执行后仍须保留来源并只抽取指定章节。"""
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp); source = root / "specs/api.md"; source.parent.mkdir()
|
||||
source.write_text("---\nowner: team\n---\n# 概述\n正文\n## API\n接口\n## 其他\n忽略\n", encoding="utf-8")
|
||||
config = root / "rules.json"
|
||||
config.write_text(json.dumps({"version": 1, "archiveRoot": "archive", "rules": [{"match": "specs/*.md", "target": "{stem}.md", "section": "API", "stripFrontmatter": True, "requiredText": ["接口"]}]}, ensure_ascii=False), encoding="utf-8")
|
||||
target = root / "archive/api.md"
|
||||
preview = self.run_script(ARCHIVE_SCRIPT, "--root", root, "--config", config)
|
||||
self.assertEqual(0, preview.returncode, preview.stderr); self.assertFalse(target.exists())
|
||||
applied = self.run_script(ARCHIVE_SCRIPT, "--root", root, "--config", config, "--apply")
|
||||
self.assertEqual(0, applied.returncode, applied.stderr)
|
||||
self.assertTrue(source.exists()); self.assertEqual("## API\n接口\n", target.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "doc",
|
||||
"version": "0.1.0",
|
||||
"description": "通用文档转换、整理与写作工具。",
|
||||
"version": "0.2.0",
|
||||
"description": "通用文档转换、表格提取、规则化归档与写作工具。",
|
||||
"author": {
|
||||
"name": "CraftKit"
|
||||
},
|
||||
@@ -9,7 +9,7 @@
|
||||
"interface": {
|
||||
"displayName": "Doc",
|
||||
"shortDescription": "文档转换、整理与写作工具",
|
||||
"longDescription": "提供 Markdown、Word、表格及结构化写作相关的通用工作流。",
|
||||
"longDescription": "提供 Markdown、Word、Excel、规则化归档及结构化写作相关的通用工作流。",
|
||||
"developerName": "CraftKit",
|
||||
"category": "Productivity",
|
||||
"capabilities": ["Read", "Write"],
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: archive
|
||||
description: 按显式配置将项目文档复制、重命名、清理 Frontmatter 或抽取 Markdown 章节到归档目录,并生成操作报告。适用于需要可重复文档归档规则的项目;删除来源、猜测业务目录或隐式查询内部系统不应触发本 Skill。
|
||||
---
|
||||
|
||||
# 文档归档
|
||||
|
||||
使用 `scripts/archive.py` 执行中性的规则化归档。规则由当前项目维护,不携带固定组织目录、业务名称、数据库表或包结构。
|
||||
|
||||
## 两阶段执行
|
||||
|
||||
1. 读取 `.craftkit/project.json` 的 `documents` 配置,或使用用户指定的规则文件。
|
||||
2. 检查每条规则的来源、目标模板、转换和校验条件。
|
||||
3. 不带 `--apply` 执行预演,向用户展示复制、跳过、冲突和失败项。
|
||||
4. 用户确认后使用相同参数加 `--apply` 执行。
|
||||
5. 检查 JSON 报告和目标文件,并确认来源文件仍然存在。
|
||||
|
||||
```text
|
||||
python scripts/archive.py --root <project> --config <rules.json> [--report <report.json>]
|
||||
python scripts/archive.py --root <project> --config <rules.json> --apply [--report <report.json>]
|
||||
```
|
||||
|
||||
## 中性平替原则
|
||||
|
||||
- 固定公司目录改为 `archiveRoot` 与规则级 `target` 模板。
|
||||
- 固定业务文件名改为 `{name}`、`{stem}`、`{suffix}`、`{relative}` 占位符。
|
||||
- 专有文档拆分逻辑改为通用 Markdown 标题章节抽取。
|
||||
- 内部数据库或服务校验改为显式 `requiredText` 内容校验;需要外部事实时由用户先提供结果,不隐式连接系统。
|
||||
- 来源专属元数据清理改为可选 `stripFrontmatter`,不会默认删除内容。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 默认只预演;`--apply` 才写入。
|
||||
- 永不删除或移动来源文件。
|
||||
- 来源必须位于项目根内,目标必须位于归档根内;拒绝绝对路径和 `..` 逃逸。
|
||||
- 冲突策略仅允许 `skip`、`overwrite`、`append`,默认 `skip`。覆盖或追加必须在用户确认的配置中明确出现。
|
||||
- 规则不执行 Shell、SQL、模板代码或网络请求。
|
||||
|
||||
完整配置见 `references/config.md`。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Archive Documents"
|
||||
short_description: "按项目规则安全预演并归档文档"
|
||||
default_prompt: "使用 $archive 根据项目规则预演文档归档,确认后再执行写入。"
|
||||
@@ -0,0 +1,32 @@
|
||||
# 归档配置
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"archiveRoot": "docs/archive",
|
||||
"defaults": { "onConflict": "skip" },
|
||||
"rules": [
|
||||
{
|
||||
"match": "design/**/*.md",
|
||||
"target": "design/{relative}",
|
||||
"stripFrontmatter": true,
|
||||
"requiredText": ["#"]
|
||||
},
|
||||
{
|
||||
"match": "specs/*.md",
|
||||
"target": "reference/{stem}-api.md",
|
||||
"section": "API"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `archiveRoot`:相对项目根的归档目录。
|
||||
- `match`:相对项目根的 glob,只处理普通文件。
|
||||
- `target`:可用 `{name}`、`{stem}`、`{suffix}`、`{relative}`、`{parent}`。
|
||||
- `section`:可选 Markdown 标题文本,输出该标题及下属内容。
|
||||
- `stripFrontmatter`:仅移除开头完整闭合的 Frontmatter。
|
||||
- `requiredText`:归档前必须出现的文字,用于替代隐式外部校验。
|
||||
- `onConflict`:`skip`、`overwrite` 或 `append`。
|
||||
|
||||
目标不得是绝对路径或逃逸归档根。`append` 仅适合文本文件。
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""按 JSON 规则预演或执行保留来源的文档归档。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def inside(path: Path, root: Path) -> bool:
|
||||
"""判断规范化路径是否位于指定根目录内。"""
|
||||
|
||||
try:
|
||||
path.relative_to(root); return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def strip_frontmatter(text: str) -> str:
|
||||
"""只移除文档开头完整闭合的 YAML Frontmatter。"""
|
||||
|
||||
return re.sub(r"\A---\s*\r?\n.*?\r?\n---\s*\r?\n?", "", text, count=1, flags=re.S)
|
||||
|
||||
|
||||
def extract_section(text: str, title: str) -> str | None:
|
||||
"""按标题文本抽取该 Markdown 章节及其子标题。"""
|
||||
|
||||
lines = text.splitlines(); start = level = None
|
||||
for index, line in enumerate(lines):
|
||||
match = re.match(r"^(#{1,6})\s+(.+?)\s*$", line)
|
||||
if match and match.group(2).strip() == title:
|
||||
start, level = index, len(match.group(1)); break
|
||||
if start is None or level is None:
|
||||
return None
|
||||
end = len(lines)
|
||||
for index in range(start + 1, len(lines)):
|
||||
match = re.match(r"^(#{1,6})\s+", lines[index])
|
||||
if match and len(match.group(1)) <= level:
|
||||
end = index; break
|
||||
return "\n".join(lines[start:end]).rstrip() + "\n"
|
||||
|
||||
|
||||
def target_for(template: str, source: Path, root: Path, archive_root: Path) -> Path:
|
||||
"""展开有限占位符,并拒绝绝对路径与目录逃逸。"""
|
||||
|
||||
relative = source.relative_to(root)
|
||||
values = {"name": source.name, "stem": source.stem, "suffix": source.suffix,
|
||||
"relative": relative.as_posix(),
|
||||
"parent": relative.parent.as_posix() if relative.parent != Path(".") else ""}
|
||||
try:
|
||||
candidate = Path(template.format(**values))
|
||||
except KeyError as error:
|
||||
raise ValueError(f"未知目标占位符:{error.args[0]}") from error
|
||||
if candidate.is_absolute():
|
||||
raise ValueError("目标模板不得生成绝对路径")
|
||||
resolved = (archive_root / candidate).resolve()
|
||||
if not inside(resolved, archive_root):
|
||||
raise ValueError("目标路径逃逸归档目录")
|
||||
return resolved
|
||||
|
||||
|
||||
def process(root: Path, config: dict, apply: bool) -> list[dict]:
|
||||
"""应用全部规则并返回可持久化的操作清单。"""
|
||||
|
||||
archive_value = config.get("archiveRoot", "docs/archive")
|
||||
archive_root = (root / archive_value).resolve()
|
||||
if Path(archive_value).is_absolute() or not inside(archive_root, root):
|
||||
raise ValueError("archiveRoot 必须是项目根内的相对路径")
|
||||
default_conflict = config.get("defaults", {}).get("onConflict", "skip")
|
||||
actions: list[dict] = []
|
||||
for rule in config.get("rules", []):
|
||||
conflict = rule.get("onConflict", default_conflict)
|
||||
if conflict not in {"skip", "overwrite", "append"}:
|
||||
raise ValueError(f"不支持的冲突策略:{conflict}")
|
||||
for source in sorted(root.glob(rule["match"])):
|
||||
# 归档目录自身不能再次成为来源,避免宽泛 glob 在重复执行时形成递归副本。
|
||||
if not source.is_file() or not inside(source.resolve(), root) or inside(source.resolve(), archive_root):
|
||||
continue
|
||||
target = target_for(rule["target"], source, root, archive_root)
|
||||
text = source.read_text(encoding="utf-8-sig")
|
||||
missing = [value for value in rule.get("requiredText", []) if value not in text]
|
||||
if missing:
|
||||
actions.append({"status": "failed", "source": str(source), "target": str(target), "reason": f"缺少必需文本:{missing}"}); continue
|
||||
if rule.get("stripFrontmatter"):
|
||||
text = strip_frontmatter(text)
|
||||
if rule.get("section"):
|
||||
section = extract_section(text, rule["section"])
|
||||
if section is None:
|
||||
actions.append({"status": "failed", "source": str(source), "target": str(target), "reason": "未找到指定章节"}); continue
|
||||
text = section
|
||||
if target.exists() and conflict == "skip":
|
||||
actions.append({"status": "skipped", "source": str(source), "target": str(target), "reason": "目标已存在"}); continue
|
||||
status = "planned"
|
||||
if apply:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists() and conflict == "append":
|
||||
prior = target.read_text(encoding="utf-8")
|
||||
target.write_text(prior.rstrip() + "\n\n" + text.lstrip(), encoding="utf-8")
|
||||
else:
|
||||
target.write_text(text, encoding="utf-8")
|
||||
status = "archived"
|
||||
actions.append({"status": status, "source": str(source), "target": str(target)})
|
||||
return actions
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""读取配置并输出 JSON 报告;默认不写归档文件。"""
|
||||
|
||||
parser = argparse.ArgumentParser(description="按规则预演或执行文档归档")
|
||||
parser.add_argument("--root", type=Path, required=True)
|
||||
parser.add_argument("--config", type=Path, required=True)
|
||||
parser.add_argument("--report", type=Path)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
root, config_path = args.root.resolve(), args.config.resolve()
|
||||
if not root.is_dir() or not config_path.is_file():
|
||||
print("错误:项目根或配置文件不存在", file=sys.stderr); return 2
|
||||
try:
|
||||
config = json.loads(config_path.read_text(encoding="utf-8-sig"))
|
||||
if config.get("version") != 1 or not isinstance(config.get("rules"), list):
|
||||
raise ValueError("配置必须使用 version 1 并包含 rules 数组")
|
||||
actions = process(root, config, args.apply)
|
||||
encoded = json.dumps({"mode": "apply" if args.apply else "dry-run", "actions": actions}, ensure_ascii=False, indent=2)
|
||||
if args.report:
|
||||
report_path = args.report.resolve(); report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(encoded + "\n", encoding="utf-8")
|
||||
print(encoded)
|
||||
return 1 if any(item["status"] == "failed" for item in actions) else 0
|
||||
except (OSError, ValueError, KeyError, json.JSONDecodeError) as error:
|
||||
print(f"错误:{error}", file=sys.stderr); return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: md-to-docx
|
||||
description: 将 Markdown 文档转换为 Word .docx,保留常见标题、段落、列表、表格、代码块、链接和本地图片。适用于需要可编辑 Word 版本的 Markdown;修订留痕、复杂排版复刻或旧版 .doc 不应触发本 Skill。
|
||||
---
|
||||
|
||||
# Markdown 转 Word
|
||||
|
||||
使用 `scripts/convert.py` 生成可编辑的 `.docx`。转换以语义结构清晰为目标,不承诺像素级复刻 Markdown 渲染结果。
|
||||
|
||||
## 执行边界
|
||||
|
||||
- 只接受存在的 `.md` 或 `.markdown` 文件。
|
||||
- 默认在输入文件旁生成同名 `.docx`;文件已存在时停止,只有用户明确同意覆盖后才传入 `--force`。
|
||||
- 用户提供 `.docx` 模板时可传入 `--template`,转换器沿用模板样式并在文档末尾追加内容,不替换模板中的占位符。
|
||||
- 不自动下载图片、字体或依赖;远程图片保留为文字提示,本地缺失图片产生警告。
|
||||
- 不伪造修订记录、批注或目录。需要人工审阅留痕时应使用独立的文档修订流程。
|
||||
|
||||
## 转换流程
|
||||
|
||||
1. 确认输入、输出位置和是否使用模板。
|
||||
2. 使用工作区依赖运行时执行:
|
||||
|
||||
```text
|
||||
python scripts/convert.py <input.md> [--output <file.docx>] [--template <template.docx>] [--force]
|
||||
```
|
||||
|
||||
3. 重新打开生成文件,检查标题、列表、表格和图片统计。
|
||||
4. 如当前环境具备 DOCX 渲染能力,渲染页面并目视检查;否则明确说明只完成了结构校验。
|
||||
5. 报告输出路径、转换统计和降级内容。
|
||||
|
||||
## 支持范围
|
||||
|
||||
转换器支持 ATX 标题、普通段落、粗体、斜体、行内代码、链接、图片、引用、围栏代码块、水平线、无序列表、有序列表和基础 Markdown 表格。
|
||||
|
||||
嵌套混合列表、原始 HTML、脚注、公式、任务列表、复杂表格合并和主题级样式可能降级。输入依赖特定 Markdown 扩展时,应先说明差异,不得声称无损转换。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Markdown to DOCX"
|
||||
short_description: "将 Markdown 转换为结构清晰的 Word 文档"
|
||||
default_prompt: "使用 $md-to-docx 将这份 Markdown 转换为 Word,并报告降级内容。"
|
||||
@@ -0,0 +1,10 @@
|
||||
# 转换支持说明
|
||||
|
||||
| Markdown 内容 | DOCX 表达 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 标题 | Heading 1 至 Heading 6 | 沿用模板同名样式 |
|
||||
| 有序、无序列表 | Word 列表样式 | 复杂混排需复核 |
|
||||
| 表格 | Word 表格 | 不支持单元格合并 |
|
||||
| 围栏代码块 | 等宽字体段落 | 不做语法高亮 |
|
||||
| 本地图片 | 内嵌图片 | 相对输入文件定位 |
|
||||
| 远程图片 | 文字提示 | 不访问网络 |
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将常见 Markdown 结构转换为可编辑 DOCX。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from docx.shared import Inches, Pt
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
"""记录生成物与转换统计。"""
|
||||
|
||||
output: Path
|
||||
headings: int = 0
|
||||
paragraphs: int = 0
|
||||
lists: int = 0
|
||||
tables: int = 0
|
||||
images: int = 0
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def cells(line: str) -> list[str]:
|
||||
"""拆分基础表格行并还原转义竖线。"""
|
||||
|
||||
return [item.strip().replace(r"\|", "|") for item in re.split(r"(?<!\\)\|", line.strip().strip("|"))]
|
||||
|
||||
|
||||
def separator(line: str) -> bool:
|
||||
"""判断 Markdown 表格分隔行。"""
|
||||
|
||||
values = cells(line)
|
||||
return bool(values) and all(re.fullmatch(r":?-{3,}:?", item) for item in values)
|
||||
|
||||
|
||||
class Converter:
|
||||
"""使用可预测的小型解析器转换常见 Markdown。"""
|
||||
|
||||
def __init__(self, source: Path, output: Path, template: Path | None) -> None:
|
||||
self.source = source
|
||||
self.doc = Document(template) if template else Document()
|
||||
self.result = Result(output)
|
||||
|
||||
def convert(self) -> Result:
|
||||
"""按块转换、保存并重新打开产物完成结构校验。"""
|
||||
|
||||
lines = self.source.read_text(encoding="utf-8-sig").splitlines()
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
if lines[index].lstrip().startswith("```"):
|
||||
index = self.add_code(lines, index)
|
||||
elif index + 1 < len(lines) and "|" in lines[index] and separator(lines[index + 1]):
|
||||
index = self.add_table(lines, index)
|
||||
else:
|
||||
self.add_line(lines[index])
|
||||
index += 1
|
||||
self.doc.save(self.result.output)
|
||||
Document(self.result.output)
|
||||
return self.result
|
||||
|
||||
def add_code(self, lines: list[str], start: int) -> int:
|
||||
"""读取围栏代码块;未闭合时输出其余内容并记录警告。"""
|
||||
|
||||
index = start + 1
|
||||
content: list[str] = []
|
||||
while index < len(lines) and not lines[index].lstrip().startswith("```"):
|
||||
content.append(lines[index])
|
||||
index += 1
|
||||
if index == len(lines):
|
||||
self.result.warnings.append("代码围栏未闭合")
|
||||
paragraph = self.doc.add_paragraph()
|
||||
run = paragraph.add_run("\n".join(content))
|
||||
run.font.name = "Consolas"
|
||||
run.font.size = Pt(9)
|
||||
self.result.paragraphs += 1
|
||||
return min(index + 1, len(lines))
|
||||
|
||||
def add_table(self, lines: list[str], start: int) -> int:
|
||||
"""将连续表格行转换为统一列数的 Word 表格。"""
|
||||
|
||||
rows = [cells(lines[start])]
|
||||
index = start + 2
|
||||
while index < len(lines) and lines[index].strip() and "|" in lines[index]:
|
||||
rows.append(cells(lines[index]))
|
||||
index += 1
|
||||
width = max(map(len, rows))
|
||||
table = self.doc.add_table(rows=len(rows), cols=width)
|
||||
table.style = "Table Grid"
|
||||
for row_no, values in enumerate(rows):
|
||||
for col_no in range(width):
|
||||
table.cell(row_no, col_no).text = (values[col_no] if col_no < len(values) else "").replace("<br>", "\n")
|
||||
self.result.tables += 1
|
||||
return index
|
||||
|
||||
def add_line(self, line: str) -> None:
|
||||
"""识别标题、列表、引用、分隔线和普通段落。"""
|
||||
|
||||
value = line.strip()
|
||||
if not value:
|
||||
return
|
||||
heading = re.match(r"^(#{1,6})\s+(.+)$", value)
|
||||
if heading:
|
||||
self.add_inline(self.doc.add_heading(level=len(heading.group(1))), heading.group(2))
|
||||
self.result.headings += 1
|
||||
return
|
||||
unordered = re.match(r"^(\s*)[-+*]\s+(.+)$", line)
|
||||
ordered = re.match(r"^(\s*)\d+[.)]\s+(.+)$", line)
|
||||
if unordered or ordered:
|
||||
match = unordered or ordered
|
||||
paragraph = self.doc.add_paragraph(style="List Bullet" if unordered else "List Number")
|
||||
paragraph.paragraph_format.left_indent = Inches(min(len(match.group(1)) // 2, 4) * 0.25)
|
||||
self.add_inline(paragraph, match.group(2))
|
||||
self.result.lists += 1
|
||||
return
|
||||
if value.startswith(">"):
|
||||
paragraph = self.doc.add_paragraph(value.lstrip("> "))
|
||||
paragraph.paragraph_format.left_indent = Inches(0.3)
|
||||
for run in paragraph.runs:
|
||||
run.italic = True
|
||||
self.result.paragraphs += 1
|
||||
return
|
||||
if re.fullmatch(r"(?:-{3,}|\*{3,}|_{3,})", value):
|
||||
self.doc.add_paragraph("────────")
|
||||
return
|
||||
paragraph = self.doc.add_paragraph()
|
||||
self.add_inline(paragraph, value)
|
||||
self.result.paragraphs += 1
|
||||
|
||||
def add_inline(self, paragraph, text: str) -> None:
|
||||
"""转换图片与基础强调;链接保留为可读地址文本。"""
|
||||
|
||||
pattern = re.compile(r"(!?\[[^\]]*\]\([^)]*\)|\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*)")
|
||||
cursor = 0
|
||||
for match in pattern.finditer(text):
|
||||
paragraph.add_run(text[cursor:match.start()])
|
||||
token = match.group(0)
|
||||
image = re.fullmatch(r"!\[([^\]]*)\]\(([^)]+)\)", token)
|
||||
link = re.fullmatch(r"\[([^\]]+)\]\(([^)]+)\)", token)
|
||||
if image:
|
||||
self.add_image(paragraph, image.group(1), image.group(2))
|
||||
elif link:
|
||||
paragraph.add_run(f"{link.group(1)}({link.group(2)})")
|
||||
else:
|
||||
run = paragraph.add_run(token[2:-2] if token.startswith("**") else token[1:-1])
|
||||
run.bold = token.startswith("**")
|
||||
run.italic = token.startswith("*") and not token.startswith("**")
|
||||
if token.startswith("`"):
|
||||
run.font.name = "Consolas"
|
||||
cursor = match.end()
|
||||
paragraph.add_run(text[cursor:])
|
||||
|
||||
def add_image(self, paragraph, alt: str, target: str) -> None:
|
||||
"""只处理本地图片,防止转换过程产生隐式网络访问。"""
|
||||
|
||||
if re.match(r"^[a-z][a-z0-9+.-]*://", target, re.I):
|
||||
paragraph.add_run(f"[远程图片:{alt or target}]")
|
||||
self.result.warnings.append(f"未下载远程图片:{target}")
|
||||
return
|
||||
path = (self.source.parent / target).resolve()
|
||||
if not path.is_file():
|
||||
paragraph.add_run(f"[缺失图片:{alt or target}]")
|
||||
self.result.warnings.append(f"本地图片不存在:{target}")
|
||||
return
|
||||
try:
|
||||
paragraph.add_run().add_picture(str(path), width=Inches(5.8))
|
||||
self.result.images += 1
|
||||
except (OSError, ValueError) as error:
|
||||
paragraph.add_run(f"[无法嵌入图片:{alt or target}]")
|
||||
self.result.warnings.append(f"图片无法嵌入:{target}({error})")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""校验输入、覆盖权限与模板后执行转换。"""
|
||||
|
||||
parser = argparse.ArgumentParser(description="将 Markdown 转换为 DOCX")
|
||||
parser.add_argument("input", type=Path)
|
||||
parser.add_argument("--output", type=Path)
|
||||
parser.add_argument("--template", type=Path)
|
||||
parser.add_argument("--force", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
source = args.input.resolve()
|
||||
output = (args.output or source.with_suffix(".docx")).resolve()
|
||||
template = args.template.resolve() if args.template else None
|
||||
if not source.is_file() or source.suffix.lower() not in {".md", ".markdown"}:
|
||||
print("错误:输入必须是存在的 .md 或 .markdown 文件", file=sys.stderr); return 2
|
||||
if output.suffix.lower() != ".docx" or (output.exists() and not args.force):
|
||||
print("错误:输出必须是可写的 .docx;覆盖需使用 --force", file=sys.stderr); return 1
|
||||
if template and (not template.is_file() or template.suffix.lower() != ".docx"):
|
||||
print("错误:模板必须是存在的 .docx 文件", file=sys.stderr); return 2
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
result = Converter(source, output, template).convert()
|
||||
except (OSError, ValueError) as error:
|
||||
print(f"错误:{error}", file=sys.stderr); return 1
|
||||
print(f"DOCX:{result.output}")
|
||||
print(f"标题:{result.headings},段落:{result.paragraphs},列表:{result.lists},表格:{result.tables},图片:{result.images}")
|
||||
for warning in dict.fromkeys(result.warnings): print(f"警告:{warning}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: xlsx-to-md
|
||||
description: 将 Excel .xlsx 工作簿转换为 Markdown,按工作表提取表格、公式或缓存值,并处理合并单元格和空工作表。适用于需要可读文本版本的现代 Excel 文件;旧版 .xls、宏、图表或公式重算不应触发本 Skill。
|
||||
---
|
||||
|
||||
# Excel 转 Markdown
|
||||
|
||||
使用 `scripts/convert.py` 将 `.xlsx` 工作簿转换为单个 Markdown 文件,每个工作表形成独立章节。
|
||||
|
||||
## 执行边界
|
||||
|
||||
- 只接受 `.xlsx`,不把 `.xls` 伪装成受支持格式。
|
||||
- 默认保留公式文本;只有用户希望读取工作簿内已有缓存值时才使用 `--values`。转换器不会计算公式。
|
||||
- 合并单元格默认将锚点值填充到合并区域,可用 `--merged anchor` 仅保留左上角值。
|
||||
- 默认在输入文件旁生成同名 `.md`,已存在时停止;覆盖必须获得用户确认并传入 `--force`。
|
||||
- 不提取宏、图表、批注、数据验证、条件格式或图片,也不自动安装依赖。
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 确认公式模式、合并单元格策略和输出路径。
|
||||
2. 使用工作区依赖运行时执行:
|
||||
|
||||
```text
|
||||
python scripts/convert.py <input.xlsx> [--output <file.md>] [--values] [--merged fill|anchor] [--force]
|
||||
```
|
||||
|
||||
3. 抽查工作表数量、标题、边界行列、公式和转义字符。
|
||||
4. 报告空工作表、公式缓存为空及未迁移对象等警告。
|
||||
|
||||
## 语义说明
|
||||
|
||||
Markdown 只能表达二维文本表格。日期以 ISO 可读格式输出,单元格换行转换为 `<br>`,竖线会转义。数字显示可能与 Excel 自定义格式不同;需要报表级显示保真时应使用电子表格工具直接查看源文件。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "XLSX to Markdown"
|
||||
short_description: "将 Excel 工作簿按工作表转换为 Markdown"
|
||||
default_prompt: "使用 $xlsx-to-md 将这个 Excel 工作簿转换为 Markdown,并说明公式和合并单元格策略。"
|
||||
@@ -0,0 +1,6 @@
|
||||
# 支持范围
|
||||
|
||||
- 支持:工作表、二维单元格、公式文本、缓存值、日期、布尔值、错误值、合并区域。
|
||||
- 降级:自定义数字格式、隐藏行列、筛选状态、富文本、超链接样式。
|
||||
- 不支持:`.xls`、VBA、图表、图片、批注、数据透视表、公式计算。
|
||||
- 默认公式模式比缓存值模式更稳定,因为缓存值可能缺失或过期。
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将 XLSX 工作簿的二维数据转换为 Markdown。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
|
||||
def display(value: object) -> str:
|
||||
"""将单元格值转换为稳定、可读且适合表格的文本。"""
|
||||
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, dt.datetime):
|
||||
value = value.isoformat(sep=" ", timespec="seconds")
|
||||
elif isinstance(value, (dt.date, dt.time)):
|
||||
value = value.isoformat()
|
||||
elif isinstance(value, bool):
|
||||
value = "TRUE" if value else "FALSE"
|
||||
text = str(value).replace("\\", "\\\\").replace("|", "\\|")
|
||||
return text.replace("\r\n", "<br>").replace("\n", "<br>").replace("\r", "<br>")
|
||||
|
||||
|
||||
def matrix_for(sheet, merged_mode: str) -> tuple[list[list[str]], int]:
|
||||
"""读取有效区域,并按选择的策略表达合并单元格。"""
|
||||
|
||||
values = [[cell.value for cell in row] for row in sheet.iter_rows()]
|
||||
merged_count = len(sheet.merged_cells.ranges)
|
||||
if merged_mode == "fill":
|
||||
for merged in sheet.merged_cells.ranges:
|
||||
anchor = values[merged.min_row - 1][merged.min_col - 1]
|
||||
for row in range(merged.min_row - 1, merged.max_row):
|
||||
for column in range(merged.min_col - 1, merged.max_col):
|
||||
values[row][column] = anchor
|
||||
while values and all(value is None for value in values[-1]):
|
||||
values.pop()
|
||||
width = max((max((i + 1 for i, value in enumerate(row) if value is not None), default=0) for row in values), default=0)
|
||||
return [[display(value) for value in row[:width]] for row in values], merged_count
|
||||
|
||||
|
||||
def render_sheet(title: str, rows: list[list[str]]) -> str:
|
||||
"""将单个工作表渲染为 Markdown 章节。"""
|
||||
|
||||
lines = [f"## {title}", ""]
|
||||
if not rows or not rows[0]:
|
||||
return "\n".join(lines + ["_空工作表_"])
|
||||
width = max(map(len, rows))
|
||||
normalized = [row + [""] * (width - len(row)) for row in rows]
|
||||
lines.extend((f"| {' | '.join(normalized[0])} |", f"| {' | '.join(['---'] * width)} |"))
|
||||
lines.extend(f"| {' | '.join(row)} |" for row in normalized[1:])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def convert(source: Path, output: Path, values_only: bool, merged_mode: str) -> tuple[int, int, int, list[str]]:
|
||||
"""打开工作簿、转换全部工作表并写入 UTF-8 Markdown。"""
|
||||
|
||||
workbook = load_workbook(source, read_only=False, data_only=values_only)
|
||||
sections = [f"# {source.stem}"]
|
||||
row_count = merged_count = 0
|
||||
warnings: list[str] = []
|
||||
for sheet in workbook.worksheets:
|
||||
rows, merged = matrix_for(sheet, merged_mode)
|
||||
sections.append(render_sheet(sheet.title, rows))
|
||||
row_count += len(rows)
|
||||
merged_count += merged
|
||||
if not rows or not rows[0]:
|
||||
warnings.append(f"空工作表:{sheet.title}")
|
||||
sheet_count = len(workbook.worksheets)
|
||||
workbook.close()
|
||||
output.write_text("\n\n".join(sections).rstrip() + "\n", encoding="utf-8")
|
||||
return sheet_count, row_count, merged_count, warnings
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""校验文件类型、覆盖授权和输出路径后执行转换。"""
|
||||
|
||||
parser = argparse.ArgumentParser(description="将 XLSX 工作簿转换为 Markdown")
|
||||
parser.add_argument("input", type=Path)
|
||||
parser.add_argument("--output", type=Path)
|
||||
parser.add_argument("--values", action="store_true", help="读取缓存值而不是公式文本")
|
||||
parser.add_argument("--merged", choices=("fill", "anchor"), default="fill")
|
||||
parser.add_argument("--force", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
source = args.input.resolve()
|
||||
output = (args.output or source.with_suffix(".md")).resolve()
|
||||
if not source.is_file() or source.suffix.lower() != ".xlsx":
|
||||
print("错误:只支持存在的 .xlsx 文件;旧版 .xls 不受支持", file=sys.stderr); return 2
|
||||
if output.suffix.lower() not in {".md", ".markdown"}:
|
||||
print("错误:输出文件必须是 Markdown", file=sys.stderr); return 2
|
||||
if output.exists() and not args.force:
|
||||
print(f"错误:输出文件已存在:{output}", file=sys.stderr); return 1
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
sheets, rows, merged, warnings = convert(source, output, args.values, args.merged)
|
||||
except (OSError, ValueError) as error:
|
||||
print(f"错误:{error}", file=sys.stderr); return 1
|
||||
print(f"Markdown:{output}")
|
||||
print(f"工作表:{sheets},数据行:{rows},合并区域:{merged}")
|
||||
for warning in warnings: print(f"警告:{warning}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -6,6 +6,7 @@
|
||||
"code": { "sourceRoots": [], "packageRoots": [], "modules": [] },
|
||||
"dependencies": { "internal": [], "public": [] },
|
||||
"commands": { "build": [], "test": [], "check": [] },
|
||||
"documents": { "archiveRoot": "docs/archive", "archiveRules": [] },
|
||||
"guidance": {
|
||||
"agentIndex": ".craftkit/agents/index.md",
|
||||
"standardsIndex": ".craftkit/standards/index.md",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- `code` 记录相对源码根、包根和模块。
|
||||
- `dependencies.internal` 只记录用户确认可在当前仓库共享的依赖标识和用途。
|
||||
- `commands` 只记录经过项目文件或用户确认的命令。
|
||||
- `documents.archiveRoot` 记录可共享的文档归档根;`documents.archiveRules` 记录项目确认的匹配、目标模板、转换和校验规则,不写入公司固定目录或外部系统凭据。
|
||||
- `guidance` 指向 `.craftkit/` 内的索引入口。
|
||||
- `initialization.references` 记录参考项目名称、用途、允许提炼范围和可共享的相对位置。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user