diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b08c1..6a1ca73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,59 @@ # Changelog +## 0.6.0 + +### Features + +- **`--url` 参数**:`download` / `dl` 与 `get-titles` 支持显式传入 `--url `,原位置参数仍兼容但会提示废弃,便于脚本和 Agent 统一命令形态 +- **Markdown 输出行数限制**:新增 `--max-output-lines` 与 `LARK_DOCX2MD_MAX_OUTPUT_LINES`,未指定标题过滤时可限制输出规模;超限会提示先用 `get-titles` 获取标题,再补充标题过滤参数重试 +- **`get-titles` text 格式说明**:text 输出文件开头增加 YAML front matter,说明标题行格式与使用 `blockId` 继续过滤的命令 + +### Fixed + +- **`get-titles` 非文档链接提示**:对 sheets 链接提前给出明确错误,避免继续进入不适用的标题读取流程 +- **飞书 URL 错误提示**:优化不支持链接的报错信息,明确支持的 wiki / docx / docs / sheets 链接类型 + +### Changed + +- **`get-titles` 输出格式简化**:保留 `text` 与 `yaml` 两种输出格式,默认改为 `text`,减少 AI/脚本选择目标标题时的噪音 +- **Agent local 输出提示增强**:`--agent local` 输出文件路径时附带 Markdown 行数,便于判断是否需要进一步按标题过滤 +- **CLI 与公共库模块拆分**:将命令注册、参数解析、业务处理与 converter、client、logger、types、URL 解析、标题过滤等公共模块拆分整理,便于后续维护与测试 +- **Skill 文档迁移**:将 skill 使用说明迁移至 `skills/lark-docx2md/README.md`,并同步 `--agent local` 工作流 + +## 0.6.0-beta.1 + +### Changed + +- **CLI 模块拆分**:将命令注册、参数解析与业务处理拆分到 `src/cli` 模块,CLI 入口仅负责启动,便于后续维护与测试 +- **公共库目录调整**:将 converter、client、logger、types、URL 解析与标题过滤等公共模块迁移到 `src/lib`,并同步更新构建入口 +- **CLI 参数测试补充**:新增 CLI options 单元测试,覆盖 agent、白板格式、图片模式、标题过滤与输出行数限制等参数解析逻辑 + +## 0.6.0-beta.0 + +### Features + +- **`--url` 参数**:`download` / `dl` 与 `get-titles` 支持显式传入 `--url `,原位置参数仍兼容但会提示废弃,便于脚本和 Agent 统一命令形态 +- **Markdown 输出行数限制**:新增 `--max-output-lines` 与 `LARK_DOCX2MD_MAX_OUTPUT_LINES`,未指定标题过滤时可限制输出规模;超限会提示先用 `get-titles` 获取标题,再补充标题过滤参数重试 + +### Fixed + +- **`get-titles` 非文档链接提示**:对 sheets 链接提前给出明确错误,避免继续进入不适用的标题读取流程 +- **飞书 URL 错误提示**:优化不支持链接的报错信息,明确支持的 wiki / docx / docs / sheets 链接类型 + +### Changed + +- **`get-titles` 输出格式简化**:保留 `text` 与 `yaml` 两种输出格式,默认改为 `text`,减少 AI/脚本选择目标标题时的噪音 +- **Agent local 输出提示增强**:`--agent local` 输出文件路径时附带 Markdown 行数,便于判断是否需要进一步按标题过滤 +- **Skill 文档迁移**:将 skill 使用说明迁移至 `skills/lark-docx2md/README.md`,并同步 `--agent local` 工作流 + ## 0.5.3 ### Features - **`--filter-title-block-id` 精确过滤**:新增 CLI 选项与 `ConvertOptions.filterTitleBlockId`,按 heading 块 id 严格匹配目标章节,彻底规避同名标题歧义;与 `--filter-title` 互斥 -- **`get-titles` 子命令**:一键列出 docx/wiki 文档全部标题(1~9 级),支持 `yaml` / `yaml-tree` / `json` / `tree` / `text` 五种输出格式与 `--max-level` 限级;不支持 sheets +- **`get-titles` 子命令**:一键列出 docx/wiki 文档全部标题(1~9 级),支持 `text` / `yaml` 两种输出格式与 `--max-level` 限级;不支持 sheets - **`--filter-title` / `--filter-title-block-id` 注入父级标题**:命中深层标题时,自动把所有更高层级的祖先标题(仅 heading 块本身)按文档顺序一并输出,保留章节层级上下文;顶级命中不注入,跳级不伪造中间层,旁支兄弟自动排除 -- **标题元信息结构化**:导出 `HeadingInfo`(`blockId` / `level` / `text`),`--filter-title` 未命中时错误信息改为与 `get-titles` 同形的 yaml 清单,AI/脚本可直接据此重选 blockId,形成错误恢复闭环 +- **标题元信息结构化**:导出 `HeadingInfo`(`blockId` / `level` / `text`),`--filter-title` 未命中时错误信息改为紧凑 text 标题清单,AI/脚本可直接据此重选 blockId,形成错误恢复闭环 ### Fixed diff --git a/README.md b/README.md index f92dd82..914e9cd 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,13 @@ > 命令所需权限见下 ‘飞书自创应用需要的权限’ ```bash -npx -y lark-docx2md@latest download https://xxx.feishu.cn/wiki/xxx --app-id cli_xxx --app-secret xxxx +npx -y lark-docx2md@latest download --url https://xxx.feishu.cn/wiki/xxx --app-id cli_xxx --app-secret xxxx ``` > `download` 命令支持别名 `dl`,以下写法等效: > > ```bash -> npx -y lark-docx2md@latest dl https://xxx.feishu.cn/wiki/xxx --app-id cli_xxx --app-secret xxxx +> npx -y lark-docx2md@latest dl --url https://xxx.feishu.cn/wiki/xxx --app-id cli_xxx --app-secret xxxx > ``` 或先设置环境变量(命令行参数可省略): @@ -31,14 +31,14 @@ npx -y lark-docx2md@latest download https://xxx.feishu.cn/wiki/xxx --app-id cli_ ```bash export LARK_DOCX2MD_APP_ID= export LARK_DOCX2MD_APP_SECRET= -npx -y lark-docx2md@latest download +npx -y lark-docx2md@latest download --url ``` ## 参数 | 参数 | 说明 | 环境变量 | 默认值 | |--------------------------|-------------------------------------------|------------------------------|-----------------------| -| `` | 飞书文档链接(`https://*.feishu.cn/wiki/*` 或 `/sheets/*`,支持 `?sheet=` 指定子表) | — | — | +| `--url ` | 飞书文档链接(`https://*.feishu.cn/wiki/*` 或 `/sheets/*`,支持 `?sheet=` 指定子表) | — | — | | `--app-id ` | 飞书应用 App ID | `LARK_DOCX2MD_APP_ID` | — | | `--app-secret ` | 飞书应用 App Secret | `LARK_DOCX2MD_APP_SECRET` | — | | `-o, --output ` | 输出目录 | `LARK_DOCX2MD_OUTPUT` | `./larkDocx2mdOutput` | @@ -46,6 +46,7 @@ npx -y lark-docx2md@latest download | `--image-mode ` | 图片处理模式:`local`(下载到本地)或 `online`(24h 临时链接) | `LARK_DOCX2MD_IMAGE_MODE` | `local` | | `--filter-title ` | 按标题过滤:仅转换匹配标题及其下级内容(匹配到同级或更高级标题时截止) | — | — | | `--filter-title-block-id <id>` | 按 heading 块 id 精确过滤(无同名歧义),通常配合 `get-titles` 子命令获取;与 `--filter-title` 互斥 | — | — | +| `--max-output-lines <n>` | 未指定标题过滤时允许输出的最大 Markdown 行数;超过会报错并提示补充标题过滤参数 | `LARK_DOCX2MD_MAX_OUTPUT_LINES` | — | | `--wb-format <format>` | 画板输出格式:`base64`、`inline-svg`、`svg`、`yaml` | `LARK_DOCX2MD_WB_FORMAT` | `svg`(agent 下默认 `yaml`) | | `--wb-bg <style>` | 画板 SVG 背景:`none`、`dot` 或颜色值如 `#fff` | `LARK_DOCX2MD_WB_BG` | `none` | | `--wb-image-mode <mode>` | 画板图片模式:`online`、`base64` 或 `local` | `LARK_DOCX2MD_WB_IMAGE_MODE` | `local` | @@ -55,8 +56,9 @@ npx -y lark-docx2md@latest download <url> > - `--agent`(在线):强制 `--image-mode=online`、`--wb-image-mode=online`;`--wb-format` 默认 `yaml`,仅允许 `inline-svg` / `yaml`;转换完成后 Markdown 直接通过 stdout 输出。 > - `--agent local`:强制 `--image-mode=local`、`--wb-image-mode=local`(Markdown、图片、画板中的图片均落盘);`--wb-format` 默认 `yaml`,仅允许 `inline-svg` / `yaml`;stdout 输出引导 AI 读取文件的提示词(包含绝对路径)。 > - 非 agent 模式下 `--wb-format yaml` 时:`--wb-image-mode` 强制为 `online`。 -> - `--filter-title`:按标题文本精确匹配(忽略前后空格),收集该标题及其所有子级块,遇到同级或更高级标题时停止。同名标题取首个;未匹配时错误信息附全文标题 yaml 清单。 +> - `--filter-title`:按标题文本精确匹配(忽略前后空格),收集该标题及其所有子级块,遇到同级或更高级标题时停止。同名标题取首个;未匹配时错误信息附全文标题 text 清单。 > - `--filter-title-block-id`:按 heading 块 id 严格相等匹配,适用于同名标题或脚本化场景;通常先用 `get-titles` 查出目标 `blockId` 再传入。与 `--filter-title` 互斥。 +> - `--max-output-lines`:仅在未指定 `--filter-title` / `--filter-title-block-id` 时生效;纯 Markdown 阶段先检查一次,图片、画板、表格等解析完成后再检查一次。超过限制时直接报错,提示先用 `get-titles` 获取标题,再补充标题过滤参数重试。 > - **命中深层标题时自动注入父级标题(仅 heading 块本身)**:两个过滤参数均会按文档顺序补齐包含路径上的顶层→该标题的所有祖先标题,以保留章节层级上下文;不会引入旁支兄弟或伪造跳级。 ## 子命令:`get-titles` @@ -64,17 +66,17 @@ npx -y lark-docx2md@latest download <url> 列出 docx/wiki 文档全部标题(不支持 sheets),配合 `--filter-title-block-id` 使用可避开同名歧义。 ```bash -npx -y lark-docx2md@latest get-titles --agent <url> +npx -y lark-docx2md@latest get-titles --agent --url <url> ``` | 参数 | 说明 | 默认值 | |-----------------------|------------------------------------------------------------------------------------------|--------| -| `<url>` | 飞书 wiki/docx URL | — | +| `--url <url>` | 飞书 wiki/docx URL | — | | `--max-level <n>` | 仅输出 `level <= n` 的标题(1~9) | `9` | -| `--format <format>` | 输出格式:`yaml`(扁平) \| `yaml-tree`(嵌套) \| `json` \| `tree`(json 嵌套) \| `text`(缩进的 markdown 标题) | `yaml` | +| `--format <format>` | 输出格式:`text`(YAML front matter + `## [blockId] 标题`) \| `yaml`(嵌套) | `text` | | `--agent [mode]` | 同 `dl`,降低日志级别 | — | -输出包含 `blockId` / `level` / `text`,可被 AI 直接消费用于选择目标章节(嵌套关系由 `yaml-tree` / `tree` 格式天然表达)。 +`text` 格式文件开头包含 YAML front matter 格式说明;正文用 `#` 数量保留标题层级,并把 `blockId` 放在标题旁,便于 AI 直接选择目标章节。 ## 功能 @@ -126,10 +128,10 @@ npx -y lark-docx2md@latest get-titles --agent <url> ```bash # 直接运行(无需构建),download 可使用别名 dl -pnpm dev download --app-id <APP_ID> --app-secret <APP_SECRET> <url> +pnpm dev download --app-id <APP_ID> --app-secret <APP_SECRET> --url <url> # 或使用环境变量 -LARK_DOCX2MD_APP_ID=<APP_ID> LARK_DOCX2MD_APP_SECRET=<APP_SECRET> pnpm dev dl <url> +LARK_DOCX2MD_APP_ID=<APP_ID> LARK_DOCX2MD_APP_SECRET=<APP_SECRET> pnpm dev dl --url <url> # 构建为 JS pnpm build diff --git a/package.json b/package.json index c93a087..0a7fe32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lark-docx2md", - "version": "0.5.3", + "version": "0.6.0", "description": "Convert Lark/Feishu documents to Markdown", "type": "module", "main": "./dist/converter.js", diff --git a/skills/README.md b/skills/README.md deleted file mode 100644 index 5fe86e3..0000000 --- a/skills/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# lark-docx2md 技能说明 - -以 Markdown 格式读取飞书链接(`https://xxx.feishu.cn/wiki/xxx` 格式)的内容 - -## 环境变量配置 - -```shell -export LARK_DOCX2MD_APP_ID="cli_xxx" -export LARK_DOCX2MD_APP_SECRET="xxx" -``` - -- `APIFOX_ACCESS_TOKEN` 飞书自建应用的 key -- `APIFOX_PROJECT_MAP` 飞书自建应用的 密钥 - - - -## 使用方式 - -- 直接使用: - -例如: ‘根据 https://xxx.feishu.cn/wiki/xxx 文档实现 xxx’ ,AI 就会自动调用此技能。 - -- 集成到 提升词 / 规范 / Skill 中: - -```md -# 搜索表单生成规范 - -此规范用于生成符合项目规范的搜索表单 - -## 前置要求(CRITICAL) - -1. **上下文中必须包含「需求文档」,若缺少则立即停止,并要求用户补充** -2. **必须先通过 lark-docx2md 技能读取「需求文档」内容并获取表单字段** -3. **...其他要求** - -> 「文档」/ 「飞书文档」都等价视为「需求文档」 - -**缺少「需求文档」时,必须使用 AskUserQuestion 工具主动询问用户** - ---- - -... 生成规范 - -``` - - diff --git a/skills/lark-docx2md/README.md b/skills/lark-docx2md/README.md new file mode 100644 index 0000000..592845d --- /dev/null +++ b/skills/lark-docx2md/README.md @@ -0,0 +1,98 @@ +# lark-docx2md + +将飞书文档或电子表格链接转换为本地 Markdown 内容,供 AI 读取、分析与落地实现。 + +## 能力范围 + +- 支持链接: + - `https://*.feishu.cn/wiki/...` + - `https://*.feishu.cn/docx/...` + - `https://*.feishu.cn/docs/...` + - `https://*.feishu.cn/sheets/...`(可带 `?sheet=xxx`) +- 支持整篇下载,也支持按“标题/章节”精准下载(`wiki/docx/docs`)。 +- 下载后可读取 Markdown 及其引用图片(含 `static/*.png` 等资源)。 + +## 前置条件 + +### 1. 环境变量 + +```bash +export LARK_DOCX2MD_APP_ID="cli_xxx" +export LARK_DOCX2MD_APP_SECRET="xxx" +# 可选:限制未指定标题过滤时的最大 Markdown 输出行数 +export LARK_DOCX2MD_MAX_OUTPUT_LINES="800" +``` + +- `LARK_DOCX2MD_APP_ID`:飞书自建应用 App ID(或 Key) +- `LARK_DOCX2MD_APP_SECRET`:飞书自建应用密钥 +- `LARK_DOCX2MD_MAX_OUTPUT_LINES`:可选,正整数。未指定标题过滤时限制允许输出的最大 Markdown 行数,超过限制会报错并提示先获取标题、再补充标题过滤参数重试。 + +### 2. 应用权限与可见范围 + +- 飞书应用需具备访问目标文档/表格的权限。 +- 目标文档需要对该应用可见(常见做法:把应用加入可访问范围,或将文档授权给应用)。 + +## 使用方式 + +当用户输入包含飞书链接的需求时,技能会按以下流程自动执行。 + +### 场景 A:`/sheets/` 链接或未指定标题 + +直接下载整份内容: + +```bash +npx -y lark-docx2md@latest dl --agent local --url "<url>" +``` + +### 场景 B:`wiki/docx/docs` 且指定了标题 + +1. 获取标题列表: + +```bash +npx -y lark-docx2md@latest get-titles --agent local --url "<url>" +``` + +2. 匹配目标标题,拿到 `blockId`。 +3. 按标题块下载: + +```bash +npx -y lark-docx2md@latest dl --agent local --filter-title-block-id "<blockId>" --url "<url>" +``` + +## 标题识别规则(给调用方) + +以下表达会被识别为“只取某章节”: + +- 显式关键词:`标题/章节/小节/部分/节选/只要/其中的 ...` +- 引号包裹:`"xxx"`、`“xxx”`、`《xxx》` 等 +- 介词结构:`X 中的 Y`、`关于 X 的内容` +- 多级路径:`A > B`、`A/ B`、`A → B` + +以下情况会回退为整篇下载: + +- 用户明确说“完整/全部/整篇” +- 无法稳定抽取到明确标题 + +## 最小示例 + +- 整篇读取: + - `根据 https://xxx.feishu.cn/wiki/abc123 输出实现方案` +- 指定章节读取: + - `基于 https://xxx.feishu.cn/docx/abc123 里“接口定义”这一节生成 TypeScript 类型` +- 读取表格: + - `分析 https://xxx.feishu.cn/sheets/abc123?sheet=xxx 并生成数据字典` + +## 常见问题 + +- 报权限/401/403: + - 检查 `LARK_DOCX2MD_APP_ID`、`LARK_DOCX2MD_APP_SECRET` 是否正确。 + - 检查飞书应用是否有文档访问权限,文档是否对应用可见。 +- 指定标题没命中: + - 先执行 `get-titles` 查看可用标题,再按准确文案重试。 +- 命令可执行但无内容: + - 确认链接可在当前账号/应用上下文访问,且不是失效分享链接。 + +## 备注 + +- `dl` 与 `get-titles` 均需带 `--agent local`。 +- 本技能重点是“读取并结构化飞书内容”,不负责业务语义正确性校验。 diff --git a/skills/lark-docx2md/SKILL.md b/skills/lark-docx2md/SKILL.md index 3e4c90b..c31808a 100644 --- a/skills/lark-docx2md/SKILL.md +++ b/skills/lark-docx2md/SKILL.md @@ -1,57 +1,77 @@ --- name: lark-docx2md description: 读取飞书 Wiki 文档或电子表格(`https://*.feishu.cn/wiki/...`、`https://*.feishu.cn/sheets/...`)内容。当需要读取、导入、归档、分析或处理飞书 wiki 或 sheets 链接的内容时使用。 +metadata: + pattern: tool-wrapper + version: "1.0.0" --- # lark-docx2md 将飞书文档或电子表格 URL 转换为本地 Markdown 文件。 -## 输入 +## 适用范围 -- **URL**(必填):`https://*.feishu.cn/{wiki,docx,docs,sheets}/...`,可带 `?sheet=xxx`。 -- **标题**(可选,仅 `wiki/docx/docs` 适用;`sheets` 跳过):用户想要的某一章节。按以下规则识别,命中即视为指定: - 1. 显式关键词「标题/章节/小节/部分/节选/只要/其中的」+ 名词短语。 - 2. 成对引号 `""` `''` `“”` `‘’` `「」` `《》` 包裹的短文本。 - 3. 介词结构:`X 中的 Y`、`X 里的 Y`、`X 那一段`、`关于 X 的内容`。 - 4. 多级路径:`A 下/中/里的 B`、`A > B`、`A/B`、`A → B` → 取为有序路径 `[A, B, ...]`。 - 5. 用户要求「完整/全部/整篇」或无法稳定抽取 → **不指定**。 - - 抽到的标题文本:去首尾空白与包裹引号;保留原文大小写、标点、空格、中英文;不翻译/改写/补全。 +- 适用链接类型:`https://*.feishu.cn/{wiki,docx,docs,sheets}/...`(含 `?sheet=xxx`)。 +- 适用任务类型:读取、导入、归档、分析、抽取飞书文档内容,并提供给后续实现或生成任务使用。 +- `wiki/docx/docs`:支持整篇读取与按标题/章节定向读取。 +- `sheets`:默认按整表读取,不做标题筛选。 +- 不适用于:非飞书链接、纯本地文件解析、需要直接写回飞书文档的场景。 ## 工作流程 -### sheets 或未指定标题 +### 第一步:识别链接类型(必须先做) + +- 若 URL 含 `/sheets/`:判定为电子表格。 +- 若 URL 含 `/wiki/`、`/docx/`、`/docs/`:判定为文档。 + +### 第二步:按类型分支处理 + +#### A. 电子表格(`/sheets/`) + +电子表格不做标题识别,直接下载整表: + +```bash +npx -y lark-docx2md@latest dl --agent local --url "<url>" +``` + +#### B. 文档(`/wiki/`、`/docx/`、`/docs/`) + +1. 先从用户请求中识别 `title`: + + 1. 显式关键词「标题/章节/小节/部分/节选/只要/其中的」+ 名词短语。 + 2. 成对引号 `""` `''` `“”` `‘’` `「」` `《》` 包裹的短文本。 + 3. 介词结构:`X 中的 Y`、`X 里的 Y`、`X 那一段`、`关于 X 的内容`。 + 4. 多级路径:`A 下/中/里的 B`、`A > B`、`A/B`、`A → B` → 取为有序路径 `[A, B, ...]`。 + + **抽到的标题文本:去首尾空白与包裹引号;保留原文大小写、标点、空格、中英文;不翻译/改写/补全。** -URL 含 `/sheets/`,或 docx/wiki 下未识别出标题,直接: +2. 如果成功识别到 `title`,则需要进一步获取 `<blockId>`: ```bash -npx -y lark-docx2md@latest dl --agent local "<url>" +npx -y lark-docx2md@latest get-titles --agent local --url "<url>" ``` -### docx/wiki 且指定标题(三步) +根据 `get-titles` 输出的所有标题信息,匹配 `title` 获取对应的 `<blockId>`。若没有明确的匹配项或存在多个匹配项,则必须让用户参与决策。 -1. 取标题表: +3. 根据识别结果查询: - ```bash - npx -y lark-docx2md@latest get-titles --agent local "<url>" - ``` +**有 `<blockId>`:** -2. 匹配标题并获取 `blockId`: - - 单标题:找 `text === <title>`。唯一直接用;多候选用上下文(父节点关键词)消歧。 - - 同父级同名兄弟:按同父节点下 children 的出现顺序列出全部候选,附上下文提示回问用户选择。 - - 无候选:报告并展示可用标题。 +```bash +npx -y lark-docx2md@latest dl --agent local --filter-title-block-id "<blockId>" --url "<url>" +``` -3. 用 `blockId` 下载: +**无 `<blockId>`:** - ```bash - npx -y lark-docx2md@latest dl --agent local --filter-title-block-id "<blockId>" "<url>" - ``` +```bash +npx -y lark-docx2md@latest dl --agent local --url "<url>" +``` ### 读取结果 从 `dl` 的 stdout 解析 Markdown 绝对路径并读取该文件;同时**必须**读取其中引用的所有图片(含画板内图片,路径形如 `static/xxx.png`)。 -## 约束 +## 重要事项 - `dl` 必须带 `--agent local`;`get-titles` 必须带 `--agent local`。 diff --git a/skills/lark-docx2md/agents/openai.yaml b/skills/lark-docx2md/agents/openai.yaml deleted file mode 100644 index c74bc90..0000000 --- a/skills/lark-docx2md/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "lark-docx2md" - short_description: "通过 lark-docx2md CLI 下载飞书 Wiki 文档或电子表格" - default_prompt: "当需要处理飞书 Wiki 或 Sheets URL 时,调用本技能运行 `npx -y lark-docx2md dl --agent local`,然后读取 stdout 中输出的绝对路径对应的 Markdown 文件。" diff --git a/src/cli.ts b/src/cli.ts index a252a79..43aa455 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,205 +1,4 @@ #!/usr/bin/env node -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { Command } from 'commander'; -import { LoggerLevel } from '@larksuiteoapi/node-sdk'; -import { convert } from './converter.js'; -import { buildTitleTree, getTitles } from './get-titles.js'; -import { setLogLevel } from './logger.js'; -import { serializeYaml } from './whiteboard/yaml/serialize.js'; -import type { SvgBackground, WbFormat, WbImageMode, AgentMode } from './types.js'; +import { runCli } from './cli/program.js'; -const program = new Command(); -program.name('larkDocx2md').description('Download Lark/Feishu documents to markdown'); - -/** - * 把 commander 解析出的 --agent 原值(undefined | true | string)与 - * LARK_DOCX2MD_AGENT 环境变量统一收敛为精确的 AgentMode | false。 - * - * 优先级:显式 --agent > 环境变量 > 默认 false。 - * 非法取值返回错误信息字符串,由调用方决定如何上报。 - */ -function resolveAgentMode (raw: boolean | string | undefined): { value: AgentMode | false; error?: string } { - if (raw === undefined) { - const env = process.env.LARK_DOCX2MD_AGENT; - if (env === 'stdout' || env === 'local') return { value: env }; - if (env !== undefined && env !== '') { - return { value: false, error: `Invalid LARK_DOCX2MD_AGENT="${env}", must be "stdout" or "local"` }; - } - return { value: false }; - } - // --agent 不带值:默认 stdout - if (raw === true) return { value: 'stdout' }; - if (raw === 'stdout' || raw === 'local') return { value: raw }; - return { value: false, error: `Invalid --agent value "${raw}", must be "stdout" or "local"` }; -} - -program - .command('download') - .alias('dl') - .description('Download a wiki document to markdown') - .option('--app-id <id>', 'Feishu app ID (or read from LARK_DOCX2MD_APP_ID)') - .option('--app-secret <secret>', 'Feishu app secret (or read from LARK_DOCX2MD_APP_SECRET)') - .option('-o, --output <dir>', 'Output directory (or LARK_DOCX2MD_OUTPUT)') - .option('--agent [mode]', 'Enable agent mode: ERROR log level, and AI-oriented stdout. Modes: "stdout" (default, print markdown to stdout) or "local" (save markdown/images/whiteboards to disk and print a read-file prompt). Or LARK_DOCX2MD_AGENT=stdout|local') - .option('--wb-format <format>', 'Whiteboard output format: "base64", "inline-svg", "svg", or "yaml" (or LARK_DOCX2MD_WB_FORMAT)') - .option('--wb-bg <style>', 'Whiteboard SVG background: "none", "dot", or a color like "#fff" (or LARK_DOCX2MD_WB_BG)') - .option('--wb-image-mode <mode>', 'Whiteboard image mode: "online", "base64", or "local" (or LARK_DOCX2MD_WB_IMAGE_MODE)') - .option('--image-mode <mode>', 'Image handling mode: "local" or "online" (or LARK_DOCX2MD_IMAGE_MODE)') - .option('--filter-title <title>', 'Only convert the section matching this heading title (single title, first match wins on duplicates)') - .option('--filter-title-block-id <id>', 'Only convert the section whose heading block id matches (most precise; obtain from get-titles)') - .argument('<url>', 'Feishu wiki document URL: https://*.feishu.cn/wiki/*') - .action(async (url: string, opts: { appId?: string; appSecret?: string; output?: string; agent?: boolean | string; imageMode?: string; wbImageMode?: string; wbBg?: SvgBackground; wbFormat?: string; filterTitle?: string; filterTitleBlockId?: string }) => { - // ─── 环境变量默认值(直接指定 > 环境变量 > 内置默认值)──────────────── - opts.appId = opts.appId ?? process.env.LARK_DOCX2MD_APP_ID; - opts.appSecret = opts.appSecret ?? process.env.LARK_DOCX2MD_APP_SECRET; - opts.output = opts.output ?? process.env.LARK_DOCX2MD_OUTPUT ?? './larkDocx2mdOutput'; - // 解析 --agent:可能为 undefined | true | 'stdout' | 'local' | 其他字符串 - const agentResolved = resolveAgentMode(opts.agent); - if (agentResolved.error) { - program.error(agentResolved.error); - return; - } - opts.agent = agentResolved.value; - const agentEnabled = opts.agent === 'stdout' || opts.agent === 'local'; - const agentLocal = opts.agent === 'local'; - const agentStdout = opts.agent === 'stdout'; - - opts.imageMode = opts.imageMode ?? process.env.LARK_DOCX2MD_IMAGE_MODE ?? 'local'; - opts.wbFormat = opts.wbFormat ?? process.env.LARK_DOCX2MD_WB_FORMAT; - opts.wbBg = opts.wbBg ?? process.env.LARK_DOCX2MD_WB_BG ?? 'none'; - opts.wbImageMode = opts.wbImageMode ?? process.env.LARK_DOCX2MD_WB_IMAGE_MODE ?? 'local'; - - // 设置 wb-format 默认值:--agent local 默认 inline-svg(兼容本地画板图片),--agent stdout 默认 yaml,其余 svg - if (!opts.wbFormat) { - if (agentLocal) opts.wbFormat = 'inline-svg'; - else if (agentStdout) opts.wbFormat = 'yaml'; - else opts.wbFormat = 'svg'; - } - - if (agentEnabled) { - setLogLevel(LoggerLevel.error); - if (agentLocal) { - // --agent local:图片/画板图片均落盘 - opts.imageMode = 'local'; - opts.wbImageMode = 'local'; - } else { - // --agent stdout:一律在线,且画板仅支持内嵌形式 - opts.imageMode = 'online'; - opts.wbImageMode = 'online'; - } - if (!['inline-svg', 'yaml'].includes(opts.wbFormat)) { - program.error(`Agent mode only supports "inline-svg" or "yaml" for --wb-format`); - } - } else { - // yaml 格式图片仅支持 online - if (opts.wbFormat === 'yaml') { - opts.wbImageMode = 'online'; - } - } - - if (opts.imageMode && !['local', 'online'].includes(opts.imageMode)) { - program.error(`Invalid --image-mode "${opts.imageMode}", must be "local" or "online"`); - } - if (!['base64', 'inline-svg', 'svg', 'yaml'].includes(opts.wbFormat)) { - program.error(`Invalid --wb-format "${opts.wbFormat}", must be "base64", "inline-svg", "svg", or "yaml"`); - } - if (!['online', 'base64', 'local'].includes(opts.wbImageMode)) { - program.error(`Invalid --wb-image-mode "${opts.wbImageMode}", must be "online", "base64", or "local"`); - } - if (opts.filterTitle && opts.filterTitleBlockId) { - program.error('--filter-title and --filter-title-block-id are mutually exclusive; choose one'); - } - - const appId = opts.appId!; - const appSecret = opts.appSecret!; - if (!appId || !appSecret) { - program.error('Missing credentials: pass --app-id/--app-secret or set LARK_DOCX2MD_APP_ID/LARK_DOCX2MD_APP_SECRET'); - } - - const result = await convert({ - appId, - appSecret, - url, - output: opts.output, - imageMode: opts.imageMode as 'local' | 'online', - wbImageMode: opts.wbImageMode as WbImageMode, - wbBg: opts.wbBg, - wbFormat: opts.wbFormat as WbFormat, - agent: opts.agent as AgentMode | false, - filterTitle: opts.filterTitle?.trim(), - filterTitleBlockId: opts.filterTitleBlockId?.trim(), - }); - - if (agentLocal) { - // 本地模式:输出引导 AI 读取文件的提示词(绝对路径) - process.stdout.write( - `**The Feishu document has been downloaded to the following absolute path:**\n\n` + - `\`${result.filePath}\`\n\n` + - `**Read this file to access the full markdown content.**\n`, - ); - } else if (agentStdout) { - process.stdout.write(result.markdown); - } - }); - -program - .command('get-titles') - .description('Print all headings (level 1~9) of a wiki/docx document as a nested yaml tree. Useful before --filter-title-block-id.') - .option('--app-id <id>', 'Feishu app ID (or read from LARK_DOCX2MD_APP_ID)') - .option('--app-secret <secret>', 'Feishu app secret (or read from LARK_DOCX2MD_APP_SECRET)') - .option('-o, --output <dir>', 'Output directory used by --agent local (or LARK_DOCX2MD_OUTPUT)') - .option('--max-level <n>', 'Only output headings whose level <= n (1~9)', '9') - .option('--agent [mode]', 'Enable agent mode: ERROR log level, AI-oriented stdout. Modes: "stdout" (default, print titles to stdout) or "local" (save titles to disk and print a read-file prompt). Or LARK_DOCX2MD_AGENT=stdout|local') - .argument('<url>', 'Feishu wiki/docx URL: https://*.feishu.cn/{wiki,docx,docs}/*') - .action(async (url: string, opts: { appId?: string; appSecret?: string; output?: string; maxLevel?: string; agent?: boolean | string }) => { - opts.appId = opts.appId ?? process.env.LARK_DOCX2MD_APP_ID; - opts.appSecret = opts.appSecret ?? process.env.LARK_DOCX2MD_APP_SECRET; - opts.output = opts.output ?? process.env.LARK_DOCX2MD_OUTPUT ?? './larkDocx2mdOutput'; - const agentResolved = resolveAgentMode(opts.agent); - if (agentResolved.error) { - program.error(agentResolved.error); - return; - } - opts.agent = agentResolved.value; - const agentEnabled = opts.agent === 'stdout' || opts.agent === 'local'; - const agentLocal = opts.agent === 'local'; - if (agentEnabled) setLogLevel(LoggerLevel.error); - - const maxLevel = parseInt(opts.maxLevel ?? '9', 10); - if (!Number.isInteger(maxLevel) || maxLevel < 1 || maxLevel > 9) { - program.error(`Invalid --max-level "${opts.maxLevel}", must be an integer in [1, 9]`); - } - - const appId = opts.appId!; - const appSecret = opts.appSecret!; - if (!appId || !appSecret) { - program.error('Missing credentials: pass --app-id/--app-secret or set LARK_DOCX2MD_APP_ID/LARK_DOCX2MD_APP_SECRET'); - } - - const result = await getTitles({ - appId, - appSecret, - url, - agent: opts.agent as AgentMode | false, - }); - const filtered = result.titles.filter(t => t.level <= maxLevel); - const tree = buildTitleTree(filtered); - const content = serializeYaml({ url: result.url, docToken: result.docToken, titles: tree }); - - if (agentLocal) { - // 本地模式:落盘 + 输出引导 AI 读取文件的提示词(绝对路径) - fs.mkdirSync(opts.output, { recursive: true }); - const filePath = path.resolve(opts.output, `${result.docToken}.titles.yaml`); - fs.writeFileSync(filePath, content); - process.stdout.write( - `**The Feishu document titles have been downloaded to the following absolute path:**\n\n` + - `\`${filePath}\`\n\n` + - `**Read this file to access the full titles list.**\n`, - ); - } else { - process.stdout.write(content); - } - }); - -program.parse(); +runCli(); diff --git a/src/cli/handlers.ts b/src/cli/handlers.ts new file mode 100644 index 0000000..bc4ed00 --- /dev/null +++ b/src/cli/handlers.ts @@ -0,0 +1,126 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { LoggerLevel } from '@larksuiteoapi/node-sdk'; +import { convert, countMarkdownLines } from '../lib/converter.js'; +import { + GET_TITLES_NON_DOCUMENT_HINT, + buildTitleTree, + getTitles, + serializeTitlesTextDocument, +} from '../lib/get-titles.js'; +import { setLogLevel } from '../lib/logger.js'; +import { parseWikiUrl } from '../lib/url.js'; +import { serializeYaml } from '../whiteboard/yaml/serialize.js'; +import { + missingCredentialsMessage, + resolveCommandUrl, + resolveDownloadOptions, + resolveGetTitlesOptions, + type DownloadRawOptions, + type GetTitlesRawOptions, +} from './options.js'; + +export type CliErrorHandler = (message: string) => never; + +export async function handleDownloadCommand ( + positionalUrl: string | undefined, + rawOptions: DownloadRawOptions, + fail: CliErrorHandler, +): Promise<void> { + const url = getCommandUrl(positionalUrl, rawOptions.url, fail); + const resolved = resolveDownloadOptions(rawOptions); + if (resolved.error || !resolved.value) fail(resolved.error ?? 'Invalid download options'); + + const opts = resolved.value; + const agentLocal = opts.agent === 'local'; + const agentStdout = opts.agent === 'stdout'; + const agentEnabled = agentLocal || agentStdout; + + if (agentEnabled) setLogLevel(LoggerLevel.error); + if (!opts.appId || !opts.appSecret) fail(missingCredentialsMessage(agentEnabled)); + + const result = await convert({ + appId: opts.appId, + appSecret: opts.appSecret, + url, + output: opts.output, + imageMode: opts.imageMode, + wbImageMode: opts.wbImageMode, + wbBg: opts.wbBg, + wbFormat: opts.wbFormat, + agent: opts.agent, + filterTitle: opts.filterTitle, + filterTitleBlockId: opts.filterTitleBlockId, + maxOutputLines: opts.maxOutputLines, + }); + + if (agentLocal) { + const lineCount = countMarkdownLines(result.markdown); + process.stdout.write( + `**The Feishu document has been downloaded to the following absolute path:**\n\n` + + `\`${result.filePath}\`. file line count: ${lineCount}\n\n` + + `**Read this file to access the full markdown content.**\n`, + ); + } else if (agentStdout) { + process.stdout.write(result.markdown); + } +} + +export async function handleGetTitlesCommand ( + positionalUrl: string | undefined, + rawOptions: GetTitlesRawOptions, + fail: CliErrorHandler, +): Promise<void> { + const url = getCommandUrl(positionalUrl, rawOptions.url, fail); + const resolved = resolveGetTitlesOptions(rawOptions); + if (resolved.error || !resolved.value) fail(resolved.error ?? 'Invalid get-titles options'); + + const opts = resolved.value; + const agentEnabled = opts.agent === 'stdout' || opts.agent === 'local'; + const agentLocal = opts.agent === 'local'; + if (agentEnabled) setLogLevel(LoggerLevel.error); + + const parsedUrl = parseWikiUrl(url); + if (parsedUrl.docType === 'sheets') { + throw new Error(GET_TITLES_NON_DOCUMENT_HINT); + } + + if (!opts.appId || !opts.appSecret) fail(missingCredentialsMessage(agentEnabled)); + + const result = await getTitles({ + appId: opts.appId, + appSecret: opts.appSecret, + url, + agent: opts.agent, + }); + const filtered = result.titles.filter(t => t.level <= opts.maxLevel); + const content = opts.format === 'yaml' + ? serializeYaml({ url: result.url, docToken: result.docToken, titles: buildTitleTree(filtered) }) + : serializeTitlesTextDocument(filtered); + + if (agentLocal) { + fs.mkdirSync(opts.output, { recursive: true }); + const ext = opts.format === 'yaml' ? 'yaml' : 'md'; + const filePath = path.resolve(opts.output, `${result.docToken}.titles.${ext}`); + fs.writeFileSync(filePath, content); + const lineCount = countMarkdownLines(content); + process.stdout.write( + `**The Feishu document titles have been downloaded to the following absolute path:**\n\n` + + `\`${filePath}\`. file line count: ${lineCount}\n\n` + + `**Read this file to access the full titles list.**\n`, + ); + } else { + process.stdout.write(content); + } +} + +function getCommandUrl ( + positionalUrl: string | undefined, + optionUrl: string | undefined, + fail: CliErrorHandler, +): string { + const resolved = resolveCommandUrl(positionalUrl, optionUrl); + if (resolved.warning) process.stderr.write(resolved.warning); + if (resolved.error || !resolved.url) fail(resolved.error ?? 'Missing document URL'); + return resolved.url; +} diff --git a/src/cli/options.ts b/src/cli/options.ts new file mode 100644 index 0000000..b8c7d6e --- /dev/null +++ b/src/cli/options.ts @@ -0,0 +1,229 @@ +import type { AgentMode, ImageMode, SvgBackground, WbFormat, WbImageMode } from '../lib/types.js'; +import type { GetTitlesFormat } from '../lib/get-titles.js'; + +export interface DownloadRawOptions { + url?: string; + appId?: string; + appSecret?: string; + output?: string; + agent?: boolean | string; + imageMode?: string; + wbImageMode?: string; + wbBg?: SvgBackground; + wbFormat?: string; + filterTitle?: string; + filterTitleBlockId?: string; + maxOutputLines?: string; +} + +export interface GetTitlesRawOptions { + url?: string; + appId?: string; + appSecret?: string; + output?: string; + maxLevel?: string; + format?: string; + agent?: boolean | string; +} + +export interface ResolvedDownloadOptions { + appId?: string; + appSecret?: string; + output: string; + agent: AgentMode | false; + imageMode: ImageMode; + wbImageMode: WbImageMode; + wbBg: SvgBackground; + wbFormat: WbFormat; + filterTitle?: string; + filterTitleBlockId?: string; + maxOutputLines?: number; +} + +export interface ResolvedGetTitlesOptions { + appId?: string; + appSecret?: string; + output: string; + agent: AgentMode | false; + maxLevel: number; + format: GetTitlesFormat; +} + +export interface UrlResolution { + url?: string; + warning?: string; + error?: string; +} + +type Env = NodeJS.ProcessEnv; + +const DEFAULT_OUTPUT = './larkDocx2mdOutput'; + +export function resolveCommandUrl (positionalUrl: string | undefined, optionUrl: string | undefined): UrlResolution { + const url = optionUrl ?? positionalUrl; + if (!url) { + return { + error: 'Missing document URL: pass --url <url>. The positional URL argument is deprecated and will be removed in the next version.', + }; + } + if (positionalUrl) { + return { + url, + warning: 'Warning: passing the URL as a positional argument is deprecated and will be removed in the next version. Use --url <url> instead.\n', + }; + } + return { url }; +} + +export function resolveAgentMode ( + raw: boolean | string | undefined, + envAgent: string | undefined, +): { value: AgentMode | false; error?: string } { + if (raw === undefined) { + if (envAgent === 'stdout' || envAgent === 'local') return { value: envAgent }; + if (envAgent !== undefined && envAgent !== '') { + return { value: false, error: `Invalid LARK_DOCX2MD_AGENT="${envAgent}", must be "stdout" or "local"` }; + } + return { value: false }; + } + + if (raw === true) return { value: 'stdout' }; + if (raw === 'stdout' || raw === 'local') return { value: raw }; + return { value: false, error: `Invalid --agent value "${raw}", must be "stdout" or "local"` }; +} + +export function missingCredentialsMessage (agentEnabled: boolean): string { + if (agentEnabled) { + return 'Missing credentials: pass --app-id/--app-secret or configure credentials in the environment.'; + } + return 'Missing credentials: pass --app-id/--app-secret or set LARK_DOCX2MD_APP_ID/LARK_DOCX2MD_APP_SECRET'; +} + +export function resolveDownloadOptions ( + raw: DownloadRawOptions, + env: Env = process.env, +): { value?: ResolvedDownloadOptions; error?: string } { + const agentResolved = resolveAgentMode(raw.agent, env.LARK_DOCX2MD_AGENT); + if (agentResolved.error) return { error: agentResolved.error }; + + const agent = agentResolved.value; + const agentLocal = agent === 'local'; + const agentStdout = agent === 'stdout'; + const agentEnabled = agentLocal || agentStdout; + + let imageMode = raw.imageMode ?? env.LARK_DOCX2MD_IMAGE_MODE ?? 'local'; + let wbFormat = raw.wbFormat ?? env.LARK_DOCX2MD_WB_FORMAT; + const wbBg = raw.wbBg ?? env.LARK_DOCX2MD_WB_BG ?? 'none'; + let wbImageMode = raw.wbImageMode ?? env.LARK_DOCX2MD_WB_IMAGE_MODE ?? 'local'; + + if (!wbFormat) { + if (agentLocal) wbFormat = 'inline-svg'; + else if (agentStdout) wbFormat = 'yaml'; + else wbFormat = 'svg'; + } + + if (agentEnabled) { + if (agentLocal) { + imageMode = 'local'; + wbImageMode = 'local'; + } else { + imageMode = 'online'; + wbImageMode = 'online'; + } + if (!isAgentWbFormat(wbFormat)) { + return { error: 'Agent mode only supports "inline-svg" or "yaml" for --wb-format' }; + } + } else if (wbFormat === 'yaml') { + wbImageMode = 'online'; + } + + if (!isImageMode(imageMode)) { + return { error: `Invalid --image-mode "${imageMode}", must be "local" or "online"` }; + } + if (!isWbFormat(wbFormat)) { + return { error: `Invalid --wb-format "${wbFormat}", must be "base64", "inline-svg", "svg", or "yaml"` }; + } + if (!isWbImageMode(wbImageMode)) { + return { error: `Invalid --wb-image-mode "${wbImageMode}", must be "online", "base64", or "local"` }; + } + if (raw.filterTitle && raw.filterTitleBlockId) { + return { error: '--filter-title and --filter-title-block-id are mutually exclusive; choose one' }; + } + + const rawMaxOutputLines = raw.maxOutputLines ?? env.LARK_DOCX2MD_MAX_OUTPUT_LINES; + const maxOutputLines = rawMaxOutputLines === undefined + ? undefined + : parsePositiveInteger(rawMaxOutputLines); + if (maxOutputLines === null) { + return { + error: `Invalid --max-output-lines / LARK_DOCX2MD_MAX_OUTPUT_LINES "${rawMaxOutputLines}", must be a positive integer`, + }; + } + + return { + value: { + appId: raw.appId ?? env.LARK_DOCX2MD_APP_ID, + appSecret: raw.appSecret ?? env.LARK_DOCX2MD_APP_SECRET, + output: raw.output ?? env.LARK_DOCX2MD_OUTPUT ?? DEFAULT_OUTPUT, + agent, + imageMode, + wbImageMode, + wbBg, + wbFormat, + filterTitle: raw.filterTitle?.trim(), + filterTitleBlockId: raw.filterTitleBlockId?.trim(), + maxOutputLines, + }, + }; +} + +export function resolveGetTitlesOptions ( + raw: GetTitlesRawOptions, + env: Env = process.env, +): { value?: ResolvedGetTitlesOptions; error?: string } { + const agentResolved = resolveAgentMode(raw.agent, env.LARK_DOCX2MD_AGENT); + if (agentResolved.error) return { error: agentResolved.error }; + + const maxLevel = parsePositiveInteger(raw.maxLevel ?? '9'); + if (maxLevel === null || maxLevel > 9) { + return { error: `Invalid --max-level "${raw.maxLevel}", must be an integer in [1, 9]` }; + } + + const format = raw.format ?? 'text'; + if (format !== 'text' && format !== 'yaml') { + return { error: `Invalid --format "${format}", must be "text" or "yaml"` }; + } + + return { + value: { + appId: raw.appId ?? env.LARK_DOCX2MD_APP_ID, + appSecret: raw.appSecret ?? env.LARK_DOCX2MD_APP_SECRET, + output: raw.output ?? env.LARK_DOCX2MD_OUTPUT ?? DEFAULT_OUTPUT, + agent: agentResolved.value, + maxLevel, + format, + }, + }; +} + +function parsePositiveInteger (raw: string): number | null { + const value = parseInt(raw, 10); + if (!Number.isInteger(value) || value < 1 || `${value}` !== raw.trim()) return null; + return value; +} + +function isImageMode (value: string): value is ImageMode { + return value === 'local' || value === 'online'; +} + +function isWbImageMode (value: string): value is WbImageMode { + return value === 'online' || value === 'base64' || value === 'local'; +} + +function isWbFormat (value: string): value is WbFormat { + return value === 'base64' || value === 'inline-svg' || value === 'svg' || value === 'yaml'; +} + +function isAgentWbFormat (value: string): value is 'inline-svg' | 'yaml' { + return value === 'inline-svg' || value === 'yaml'; +} diff --git a/src/cli/program.ts b/src/cli/program.ts new file mode 100644 index 0000000..4f4a1fc --- /dev/null +++ b/src/cli/program.ts @@ -0,0 +1,50 @@ +import { Command } from 'commander'; +import { handleDownloadCommand, handleGetTitlesCommand } from './handlers.js'; +import type { DownloadRawOptions, GetTitlesRawOptions } from './options.js'; + +export function createProgram (): Command { + const program = new Command(); + program.name('larkDocx2md').description('Download Lark/Feishu documents to markdown'); + + program + .command('download') + .alias('dl') + .description('Download a wiki document to markdown') + .option('--url <url>', 'Feishu wiki/document URL') + .option('--app-id <id>', 'Feishu app ID (or read from LARK_DOCX2MD_APP_ID)') + .option('--app-secret <secret>', 'Feishu app secret (or read from LARK_DOCX2MD_APP_SECRET)') + .option('-o, --output <dir>', 'Output directory (or LARK_DOCX2MD_OUTPUT)') + .option('--agent [mode]', 'Enable agent mode: ERROR log level, and AI-oriented stdout. Modes: "stdout" (default, print markdown to stdout) or "local" (save markdown/images/whiteboards to disk and print a read-file prompt). Or LARK_DOCX2MD_AGENT=stdout|local') + .option('--wb-format <format>', 'Whiteboard output format: "base64", "inline-svg", "svg", or "yaml" (or LARK_DOCX2MD_WB_FORMAT)') + .option('--wb-bg <style>', 'Whiteboard SVG background: "none", "dot", or a color like "#fff" (or LARK_DOCX2MD_WB_BG)') + .option('--wb-image-mode <mode>', 'Whiteboard image mode: "online", "base64", or "local" (or LARK_DOCX2MD_WB_IMAGE_MODE)') + .option('--image-mode <mode>', 'Image handling mode: "local" or "online" (or LARK_DOCX2MD_IMAGE_MODE)') + .option('--filter-title <title>', 'Only convert the section matching this heading title (single title, first match wins on duplicates)') + .option('--filter-title-block-id <id>', 'Only convert the section whose heading block id matches (most precise; obtain from get-titles)') + .option('--max-output-lines <n>', 'Maximum allowed markdown lines when no heading filter is specified; errors if exceeded (or LARK_DOCX2MD_MAX_OUTPUT_LINES)') + .argument('[url]', 'Deprecated: Feishu wiki document URL. Use --url <url> instead.') + .action((positionalUrl: string | undefined, opts: DownloadRawOptions) => ( + handleDownloadCommand(positionalUrl, opts, message => program.error(message)) + )); + + program + .command('get-titles') + .description('Print all headings (level 1~9) of a wiki/docx document. Useful before --filter-title-block-id.') + .option('--url <url>', 'Feishu wiki/docx URL') + .option('--app-id <id>', 'Feishu app ID (or read from LARK_DOCX2MD_APP_ID)') + .option('--app-secret <secret>', 'Feishu app secret (or read from LARK_DOCX2MD_APP_SECRET)') + .option('-o, --output <dir>', 'Output directory used by --agent local (or LARK_DOCX2MD_OUTPUT)') + .option('--max-level <n>', 'Only output headings whose level <= n (1~9)', '9') + .option('--format <format>', 'Output format: "text" or "yaml"', 'text') + .option('--agent [mode]', 'Enable agent mode: ERROR log level, AI-oriented stdout. Modes: "stdout" (default, print titles to stdout) or "local" (save titles to disk and print a read-file prompt). Or LARK_DOCX2MD_AGENT=stdout|local') + .argument('[url]', 'Deprecated: Feishu wiki/docx URL. Use --url <url> instead.') + .action((positionalUrl: string | undefined, opts: GetTitlesRawOptions) => ( + handleGetTitlesCommand(positionalUrl, opts, message => program.error(message)) + )); + + return program; +} + +export function runCli (argv: string[] = process.argv): void { + createProgram().parse(argv); +} diff --git a/src/client.ts b/src/lib/client.ts similarity index 100% rename from src/client.ts rename to src/lib/client.ts diff --git a/src/converter.ts b/src/lib/converter.ts similarity index 65% rename from src/converter.ts rename to src/lib/converter.ts index edc1765..af91908 100644 --- a/src/converter.ts +++ b/src/lib/converter.ts @@ -2,10 +2,10 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { LoggerLevel } from '@larksuiteoapi/node-sdk'; import { createClient } from './client.js'; -import { Parser } from './md-ast/parser.js'; -import { registerBuiltinParsers } from './md-ast/parsers/index.js'; -import { MdSerializer, registerBuiltinSerializers } from './md-ast/serializer.js'; -import { MdTransformer } from './md-ast/transformer.js'; +import { Parser } from '../md-ast/parser.js'; +import { registerBuiltinParsers } from '../md-ast/parsers/index.js'; +import { MdSerializer, registerBuiltinSerializers } from '../md-ast/serializer.js'; +import { MdTransformer } from '../md-ast/transformer.js'; import { createLogger } from './logger.js'; import type { ConvertOptions, ConvertResult, DocxBlock } from './types.js'; import { @@ -13,8 +13,7 @@ import { createTitleBlockIdFilter, type TitleFilterResult, } from './title-filter.js'; -import { serializeYaml } from './whiteboard/yaml/serialize.js'; -import { buildTitleTree } from './get-titles.js'; +import { serializeTitlesTextDocument } from './get-titles.js'; import { parseWikiUrl } from './url.js'; // 保留 “parseWikiUrl from converter” 公开 API(供外部代码 / 测试向后兼容) @@ -44,7 +43,7 @@ export async function convert (opts: ConvertOptions): Promise<ConvertResult> { objType = 'sheet'; } - let ast: import('./md-ast/types.js').MdBlockNode; + let ast: import('../md-ast/types.js').MdBlockNode; if (objType === 'sheet') { // 独立 sheet 流程:有 sheetId 时拼接为 token_sheetId 格式 @@ -77,14 +76,30 @@ export async function convert (opts: ConvertOptions): Promise<ConvertResult> { ast = parser.parse(doc, blocks); } + const serializer = createMarkdownSerializer(); + const sourceType = objType === 'sheet' ? 'sheet' : 'docx'; + + // 先按当前 AST 做一次轻量 markdown 行数检查,避免纯文本大文档继续下载图片/画板/表格。 + assertOutputLineLimit({ + markdown: serializer.serialize(ast, { sourceType }), + maxOutputLines: opts.maxOutputLines, + hasTitleFilter: hasTitleFilter(opts), + stage: 'initial markdown', + }); + // 异步后处理 const transformer = new MdTransformer(client, opts, objType === 'sheet' ? 'sheet' : 'docx'); await transformer.transform(ast); // 序列化为 markdown - const serializer = new MdSerializer(); - registerBuiltinSerializers(serializer); - const markdown = serializer.serialize(ast, { sourceType: objType === 'sheet' ? 'sheet' : 'docx' }); + const markdown = serializer.serialize(ast, { sourceType }); + + assertOutputLineLimit({ + markdown, + maxOutputLines: opts.maxOutputLines, + hasTitleFilter: hasTitleFilter(opts), + stage: 'final markdown', + }); // 输出:非 agent 模式、或 agent='local' 模式都需要落盘 let filePath: string | undefined; @@ -110,15 +125,47 @@ function createDocxFilter (opts: ConvertOptions): { return null; } -function buildFilterErrorMessage (opts: ConvertOptions, result: TitleFilterResult, url: string, docToken: string): string { +function createMarkdownSerializer (): MdSerializer { + const serializer = new MdSerializer(); + registerBuiltinSerializers(serializer); + return serializer; +} + +function hasTitleFilter (opts: ConvertOptions): boolean { + return Boolean(opts.filterTitle || opts.filterTitleBlockId); +} + +export function countMarkdownLines (markdown: string): number { + if (!markdown) return 0; + return markdown.split('\n').length; +} + +export function assertOutputLineLimit (params: { + markdown: string; + maxOutputLines?: number; + hasTitleFilter: boolean; + stage: string; +}): void { + if (!params.maxOutputLines || params.hasTitleFilter) return; + const lineCount = countMarkdownLines(params.markdown); + if (lineCount <= params.maxOutputLines) return; + + throw new Error( + `Markdown output has ${lineCount} lines after ${params.stage}, exceeding ${params.maxOutputLines}. ` + + `Please narrow the document with a heading filter: run get-titles to find the heading, then retry dl with --filter-title-block-id.`, + ); +} + +export function buildFilterErrorMessage (opts: ConvertOptions, result: TitleFilterResult, _url: string, _docToken: string): string { let target: string; if (opts.filterTitleBlockId) target = `block id "${opts.filterTitleBlockId}"`; else target = `"${opts.filterTitle}"`; + let msg = `No heading matched ${target}. Please verify the heading text/id.`; + if (result.availableHeadings.length > 0) { - const tree = buildTitleTree(result.availableHeadings); - const yaml = serializeYaml({ url, docToken, titles: tree }); - msg += `\n\nFull title list of the document:\n\n${yaml}`; + const text = serializeTitlesTextDocument(result.availableHeadings); + msg += `\n\nFull title list of the document:\n\n${text}`; } return msg; } diff --git a/src/get-titles.ts b/src/lib/get-titles.ts similarity index 72% rename from src/get-titles.ts rename to src/lib/get-titles.ts index 5d26003..48aa64d 100644 --- a/src/get-titles.ts +++ b/src/lib/get-titles.ts @@ -6,6 +6,7 @@ import { createHeadingCollector, type HeadingInfo } from './title-filter.js'; import type { AgentMode } from './types.js'; const logger = createLogger('get-titles'); +export const GET_TITLES_NON_DOCUMENT_HINT = 'The input link is not a document. There is no need to call get-titles; call dl directly with the same URL.'; // ─── Options & Result ─────────────────────────────────────────────────────── @@ -29,7 +30,7 @@ export interface GetTitlesResult { export async function getTitles (opts: GetTitlesOptions): Promise<GetTitlesResult> { const { docType, docToken: rawToken } = parseWikiUrl(opts.url); if (docType === 'sheets') { - throw new Error('get-titles does not support spreadsheets, only docx/wiki documents are supported'); + throw new Error(GET_TITLES_NON_DOCUMENT_HINT); } const sdkLoggerLevel = opts.agent ? LoggerLevel.error : LoggerLevel.warn; @@ -41,7 +42,7 @@ export async function getTitles (opts: GetTitlesOptions): Promise<GetTitlesResul docToken = node.obj_token!; logger.info('Resolved wiki node:', node.obj_type, docToken); if (node.obj_type === 'sheet') { - throw new Error('get-titles does not support spreadsheets (wiki node points to a spreadsheet), only docx/wiki documents are supported'); + throw new Error(GET_TITLES_NON_DOCUMENT_HINT); } } @@ -59,6 +60,16 @@ export interface TitleTreeNode extends HeadingInfo { children?: TitleTreeNode[]; } +export type GetTitlesFormat = 'yaml' | 'text'; + +export const TITLES_TEXT_FORMAT_COMMENT = `--- +format: lark-docx2md.titles +line_format: <markdown_heading> [<blockId>] <title> +use_block_id_with: npx -y lark-docx2md@latest dl --filter-title-block-id "<blockId>" --url "<url>" +--- + +`; + /** 按 level 栈式回溯将扁平标题列表转为树(容忍跳级标题)。 */ export function buildTitleTree (titles: HeadingInfo[]): TitleTreeNode[] { const roots: TitleTreeNode[] = []; @@ -76,3 +87,16 @@ export function buildTitleTree (titles: HeadingInfo[]): TitleTreeNode[] { } return roots; } + +/** 输出紧凑标题清单:用 markdown heading marker 表达层级,并把 blockId 放在标题旁。 */ +export function serializeTitlesText (titles: HeadingInfo[]): string { + if (titles.length === 0) return ''; + return titles + .map(t => `${'#'.repeat(t.level)} [${t.blockId}] ${t.text}`) + .join('\n') + '\n'; +} + +/** 输出面向用户/Agent 读取的完整 text 文档,文件开头包含格式说明。 */ +export function serializeTitlesTextDocument (titles: HeadingInfo[]): string { + return TITLES_TEXT_FORMAT_COMMENT + serializeTitlesText(titles); +} diff --git a/src/logger.ts b/src/lib/logger.ts similarity index 100% rename from src/logger.ts rename to src/lib/logger.ts diff --git a/src/title-filter.ts b/src/lib/title-filter.ts similarity index 100% rename from src/title-filter.ts rename to src/lib/title-filter.ts diff --git a/src/types.ts b/src/lib/types.ts similarity index 95% rename from src/types.ts rename to src/lib/types.ts index 5ac9b73..e714ef0 100644 --- a/src/types.ts +++ b/src/lib/types.ts @@ -54,6 +54,8 @@ export interface ConvertOptions { filterTitle?: string; /** 按 heading 块 id 过滤:最精确,避开一切同名歧义 */ filterTitleBlockId?: string; + /** 未指定标题过滤时,允许输出的最大 markdown 行数 */ + maxOutputLines?: number; } export interface ConvertResult { diff --git a/src/url.ts b/src/lib/url.ts similarity index 73% rename from src/url.ts rename to src/lib/url.ts index 7e8f4fc..c062159 100644 --- a/src/url.ts +++ b/src/lib/url.ts @@ -13,7 +13,11 @@ export interface ParsedFeishuUrl { export function parseWikiUrl (url: string): ParsedFeishuUrl { const m = url.match(/^https:\/\/[\w.-]+\/(docs|docx|wiki|sheets)\/([a-zA-Z0-9]+)/); - if (!m) throw new Error('Invalid feishu document URL'); + if (!m) { + throw new Error( + 'Invalid Feishu/Lark document URL. Expected an HTTPS URL like https://*.feishu.cn/wiki/<token>, https://*.feishu.cn/docx/<token>, https://*.feishu.cn/docs/<token>, or https://*.feishu.cn/sheets/<token>.' + ); + } // 解析 ?sheet=XXX 查询参数(仅对 sheets 类型有意义) const sheetId = new URL(url).searchParams.get('sheet') ?? undefined; return { docType: m[1]!, docToken: m[2]!, sheetId }; diff --git a/src/md-ast/parser.ts b/src/md-ast/parser.ts index c032139..e9bd009 100644 --- a/src/md-ast/parser.ts +++ b/src/md-ast/parser.ts @@ -1,5 +1,5 @@ import { Registry } from '../core/registry.js'; -import type { DocInfo, DocxBlock, TextBody, TextElement } from '../types.js'; +import type { DocInfo, DocxBlock, TextBody, TextElement } from '../lib/types.js'; import type { MdBlockNode, MdInlineNode } from './types.js'; export interface BlockParser { diff --git a/src/md-ast/parsers/bullet.ts b/src/md-ast/parsers/bullet.ts index f0cfdd3..caacaae 100644 --- a/src/md-ast/parsers/bullet.ts +++ b/src/md-ast/parsers/bullet.ts @@ -1,5 +1,5 @@ import type { BlockParser, ParserContext } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode } from '../types.js'; export const bulletParser: BlockParser = { diff --git a/src/md-ast/parsers/callout.ts b/src/md-ast/parsers/callout.ts index b224573..4a5e3de 100644 --- a/src/md-ast/parsers/callout.ts +++ b/src/md-ast/parsers/callout.ts @@ -1,5 +1,5 @@ import type { BlockParser, ParserContext } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode } from '../types.js'; export const calloutParser: BlockParser = { diff --git a/src/md-ast/parsers/code.ts b/src/md-ast/parsers/code.ts index 5c5c1db..1100773 100644 --- a/src/md-ast/parsers/code.ts +++ b/src/md-ast/parsers/code.ts @@ -1,5 +1,5 @@ import type { BlockParser, ParserContext } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode } from '../types.js'; const codeLangMap: Record<number, string> = { diff --git a/src/md-ast/parsers/divider.ts b/src/md-ast/parsers/divider.ts index ad858ca..07a1ef4 100644 --- a/src/md-ast/parsers/divider.ts +++ b/src/md-ast/parsers/divider.ts @@ -1,5 +1,5 @@ import type { BlockParser } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode } from '../types.js'; export const dividerParser: BlockParser = { diff --git a/src/md-ast/parsers/equation.ts b/src/md-ast/parsers/equation.ts index 52188a2..555288b 100644 --- a/src/md-ast/parsers/equation.ts +++ b/src/md-ast/parsers/equation.ts @@ -1,5 +1,5 @@ import type { BlockParser, ParserContext } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode } from '../types.js'; export const equationParser: BlockParser = { diff --git a/src/md-ast/parsers/grid.ts b/src/md-ast/parsers/grid.ts index ca8302d..d3ac172 100644 --- a/src/md-ast/parsers/grid.ts +++ b/src/md-ast/parsers/grid.ts @@ -1,5 +1,5 @@ import type { BlockParser, ParserContext } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode } from '../types.js'; export const gridParser: BlockParser = { diff --git a/src/md-ast/parsers/heading.ts b/src/md-ast/parsers/heading.ts index 717b3ba..16bb29b 100644 --- a/src/md-ast/parsers/heading.ts +++ b/src/md-ast/parsers/heading.ts @@ -1,5 +1,5 @@ import type { BlockParser, ParserContext } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode } from '../types.js'; function createHeadingParser (level: number, blockType: number): BlockParser { diff --git a/src/md-ast/parsers/image.ts b/src/md-ast/parsers/image.ts index df32df3..e2beaff 100644 --- a/src/md-ast/parsers/image.ts +++ b/src/md-ast/parsers/image.ts @@ -1,5 +1,5 @@ import type { BlockParser } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode } from '../types.js'; export const imageParser: BlockParser = { diff --git a/src/md-ast/parsers/ordered.ts b/src/md-ast/parsers/ordered.ts index e7a6289..bf8f58b 100644 --- a/src/md-ast/parsers/ordered.ts +++ b/src/md-ast/parsers/ordered.ts @@ -1,5 +1,5 @@ import type { BlockParser, ParserContext } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode } from '../types.js'; export const orderedParser: BlockParser = { diff --git a/src/md-ast/parsers/page.ts b/src/md-ast/parsers/page.ts index 0ad1f5a..f6a2ce2 100644 --- a/src/md-ast/parsers/page.ts +++ b/src/md-ast/parsers/page.ts @@ -1,5 +1,5 @@ import type { BlockParser, ParserContext } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode } from '../types.js'; export const pageParser: BlockParser = { diff --git a/src/md-ast/parsers/quote.ts b/src/md-ast/parsers/quote.ts index 7bf0702..8f449a8 100644 --- a/src/md-ast/parsers/quote.ts +++ b/src/md-ast/parsers/quote.ts @@ -1,5 +1,5 @@ import type { BlockParser, ParserContext } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode } from '../types.js'; export const quoteParser: BlockParser = { diff --git a/src/md-ast/parsers/quoteContainer.ts b/src/md-ast/parsers/quoteContainer.ts index dcced08..5c620ef 100644 --- a/src/md-ast/parsers/quoteContainer.ts +++ b/src/md-ast/parsers/quoteContainer.ts @@ -1,5 +1,5 @@ import type { BlockParser, ParserContext } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode } from '../types.js'; export const quoteContainerParser: BlockParser = { diff --git a/src/md-ast/parsers/sheet.ts b/src/md-ast/parsers/sheet.ts index 411df2d..f24cd8a 100644 --- a/src/md-ast/parsers/sheet.ts +++ b/src/md-ast/parsers/sheet.ts @@ -1,5 +1,5 @@ import type { BlockParser, ParserContext } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode } from '../types.js'; export const sheetBlockParser: BlockParser = { diff --git a/src/md-ast/parsers/table.ts b/src/md-ast/parsers/table.ts index ff8ce9c..b913c68 100644 --- a/src/md-ast/parsers/table.ts +++ b/src/md-ast/parsers/table.ts @@ -1,5 +1,5 @@ import type { BlockParser, ParserContext } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode, MdInlineNode, MdTableRow } from '../types.js'; export const tableParser: BlockParser = { diff --git a/src/md-ast/parsers/text.ts b/src/md-ast/parsers/text.ts index 19650d0..cef7024 100644 --- a/src/md-ast/parsers/text.ts +++ b/src/md-ast/parsers/text.ts @@ -1,5 +1,5 @@ import type { BlockParser, ParserContext } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode } from '../types.js'; export const textParser: BlockParser = { diff --git a/src/md-ast/parsers/todo.ts b/src/md-ast/parsers/todo.ts index e112eef..77651b5 100644 --- a/src/md-ast/parsers/todo.ts +++ b/src/md-ast/parsers/todo.ts @@ -1,5 +1,5 @@ import type { BlockParser, ParserContext } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode } from '../types.js'; export const todoParser: BlockParser = { diff --git a/src/md-ast/parsers/whiteboard.ts b/src/md-ast/parsers/whiteboard.ts index 9a9d365..b4e2051 100644 --- a/src/md-ast/parsers/whiteboard.ts +++ b/src/md-ast/parsers/whiteboard.ts @@ -1,5 +1,5 @@ import type { BlockParser } from '../parser.js'; -import type { DocxBlock } from '../../types.js'; +import type { DocxBlock } from '../../lib/types.js'; import type { MdBlockNode } from '../types.js'; export const whiteboardParser: BlockParser = { diff --git a/src/md-ast/transformer.ts b/src/md-ast/transformer.ts index 53c1aa0..6cc1c7e 100644 --- a/src/md-ast/transformer.ts +++ b/src/md-ast/transformer.ts @@ -1,13 +1,13 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import type { LarkClient } from '../client.js'; -import type { ConvertOptions, WbImageMode } from '../types.js'; +import type { LarkClient } from '../lib/client.js'; +import type { ConvertOptions, WbImageMode } from '../lib/types.js'; import { whiteboardNodesToSvg } from '../whiteboard/index.js'; import { whiteboardNodesToYaml } from '../whiteboard/yaml/index.js'; import { filterNodes } from '../whiteboard/utils.js'; import { cellToMd, expandMerges, trimTrailingEmpty, columnIndexToLetter } from '../sheet/index.js'; import type { ResolvedSheet } from '../sheet/index.js'; -import { createLogger } from '../logger.js'; +import { createLogger } from '../lib/logger.js'; import type { MdBlockNode } from './types.js'; const logger = createLogger('transformer'); @@ -98,7 +98,7 @@ export class MdTransformer { private async processWhiteboardSvg ( token: string, - wbNodes: import('../types.js').WhiteboardNode[], + wbNodes: import('../lib/types.js').WhiteboardNode[], ): Promise<MdBlockNode> { const effectiveNodes = this.opts.wbFormat === 'inline-svg' ? filterNodes(wbNodes, n => n.type !== 'paint' && n.type !== 'svg') @@ -143,7 +143,7 @@ export class MdTransformer { private async processWhiteboardYaml ( token: string, - wbNodes: import('../types.js').WhiteboardNode[], + wbNodes: import('../lib/types.js').WhiteboardNode[], ): Promise<MdBlockNode> { const { yaml: yamlContent, imageTokens } = whiteboardNodesToYaml(wbNodes); let resolvedYaml = yamlContent; diff --git a/src/whiteboard/index.ts b/src/whiteboard/index.ts index ad070b8..fd69ecd 100644 --- a/src/whiteboard/index.ts +++ b/src/whiteboard/index.ts @@ -1,5 +1,5 @@ import type { BBox, WNode } from './types.js'; -import type { SvgBackground } from '../types.js'; +import type { SvgBackground } from '../lib/types.js'; import { PADDING, r } from './utils.js'; import { renderDefs } from './defs.js'; import { DefaultRenderContext } from './render-context.js'; @@ -69,7 +69,7 @@ export function prepareNodeContext (nodes: WNode[]): NodeContext { // ─── Main Entry ────────────────────────────────────────────────────────────── -export type { SvgBackground } from '../types.js'; +export type { SvgBackground } from '../lib/types.js'; /** * 将飞书画板节点数组转换为 SVG 字符串 diff --git a/src/whiteboard/types.ts b/src/whiteboard/types.ts index c16614f..6e4fa31 100644 --- a/src/whiteboard/types.ts +++ b/src/whiteboard/types.ts @@ -1,4 +1,4 @@ -import type { WhiteboardNode } from '../types.js'; +import type { WhiteboardNode } from '../lib/types.js'; // ─── Types ─────────────────────────────────────────────────────────────────── diff --git a/tests/cli-options.test.ts b/tests/cli-options.test.ts new file mode 100644 index 0000000..7fccf3d --- /dev/null +++ b/tests/cli-options.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveAgentMode, + resolveCommandUrl, + resolveDownloadOptions, + resolveGetTitlesOptions, +} from '../src/cli/options.js'; + +describe('cli options', () => { + it('resolves command URL and keeps positional deprecation warning', () => { + expect(resolveCommandUrl(undefined, 'https://example.com/doc')).toEqual({ + url: 'https://example.com/doc', + }); + + const resolved = resolveCommandUrl('https://example.com/old', undefined); + expect(resolved.url).toBe('https://example.com/old'); + expect(resolved.warning).toContain('positional argument is deprecated'); + }); + + it('resolves agent mode from cli value before env', () => { + expect(resolveAgentMode(true, undefined).value).toBe('stdout'); + expect(resolveAgentMode('local', 'stdout').value).toBe('local'); + expect(resolveAgentMode(undefined, 'stdout').value).toBe('stdout'); + expect(resolveAgentMode('bad', undefined).error).toContain('Invalid --agent value'); + }); + + it('applies download defaults and agent stdout overrides', () => { + const resolved = resolveDownloadOptions( + { agent: 'stdout', wbFormat: 'yaml' }, + { + LARK_DOCX2MD_APP_ID: 'app-id', + LARK_DOCX2MD_APP_SECRET: 'app-secret', + LARK_DOCX2MD_OUTPUT: './out', + }, + ); + + expect(resolved.error).toBeUndefined(); + expect(resolved.value).toMatchObject({ + appId: 'app-id', + appSecret: 'app-secret', + output: './out', + agent: 'stdout', + imageMode: 'online', + wbImageMode: 'online', + wbFormat: 'yaml', + }); + }); + + it('rejects mutually exclusive heading filters', () => { + const resolved = resolveDownloadOptions({ + filterTitle: 'Intro', + filterTitleBlockId: 'block-id', + }, {}); + + expect(resolved.error).toContain('mutually exclusive'); + }); + + it('forces yaml whiteboard images online outside agent mode', () => { + const resolved = resolveDownloadOptions({ + wbFormat: 'yaml', + wbImageMode: 'local', + }, {}); + + expect(resolved.value?.wbImageMode).toBe('online'); + }); + + it('resolves get-titles format and max level', () => { + const resolved = resolveGetTitlesOptions({ + format: 'yaml', + maxLevel: '3', + }, { + LARK_DOCX2MD_AGENT: 'local', + }); + + expect(resolved.value).toMatchObject({ + agent: 'local', + format: 'yaml', + maxLevel: 3, + }); + }); +}); diff --git a/tests/converter.test.ts b/tests/converter.test.ts index c961138..ea7dbd6 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { parseWikiUrl } from '../src/converter.js'; -import { buildTitleTree } from '../src/get-titles.js'; -import type { HeadingInfo } from '../src/title-filter.js'; +import { assertOutputLineLimit, buildFilterErrorMessage, countMarkdownLines, parseWikiUrl } from '../src/lib/converter.js'; +import { + GET_TITLES_NON_DOCUMENT_HINT, + buildTitleTree, + getTitles, + serializeTitlesText, + serializeTitlesTextDocument, +} from '../src/lib/get-titles.js'; +import type { HeadingInfo } from '../src/lib/title-filter.js'; describe('parseWikiUrl', () => { it('parses docx URL', () => { @@ -35,7 +41,7 @@ describe('parseWikiUrl', () => { }); it('throws on invalid URL', () => { - expect(() => parseWikiUrl('https://example.com/invalid/path')).toThrow('Invalid feishu document URL'); + expect(() => parseWikiUrl('https://example.com/invalid/path')).toThrow('Expected an HTTPS URL like'); expect(() => parseWikiUrl('not a url')).toThrow(); }); @@ -92,3 +98,92 @@ describe('buildTitleTree', () => { expect(tree).toHaveLength(2); }); }); + +describe('serializeTitlesText', () => { + it('serializes headings as compact markdown-like lines', () => { + expect(serializeTitlesText([ + h('h1', 1, 'A'), + h('h2', 2, 'B'), + h('h7', 7, 'Deep'), + ])).toBe('# [h1] A\n## [h2] B\n####### [h7] Deep\n'); + }); + + it('returns empty output for empty headings', () => { + expect(serializeTitlesText([])).toBe(''); + }); + + it('serializes a complete text document with front matter', () => { + expect(serializeTitlesTextDocument([ + h('h1', 1, 'A'), + h('h2', 2, 'B'), + ])).toBe(`--- +format: lark-docx2md.titles +line_format: <markdown_heading> [<blockId>] <title> +use_block_id_with: npx -y lark-docx2md@latest dl --filter-title-block-id "<blockId>" --url "<url>" +--- + +# [h1] A +## [h2] B +`); + }); +}); + +describe('getTitles', () => { + it('tells users to call dl directly for sheets links', async () => { + await expect(getTitles({ + appId: '', + appSecret: '', + url: 'https://example.feishu.cn/sheets/abc123', + })).rejects.toThrow(GET_TITLES_NON_DOCUMENT_HINT); + }); +}); + +describe('output line limit', () => { + it('counts markdown lines directly', () => { + expect(countMarkdownLines('')).toBe(0); + expect(countMarkdownLines('a\nb\n\n')).toBe(4); + expect(countMarkdownLines('a\r\nb\rc')).toBe(2); + }); + + it('throws when unfiltered output exceeds the limit', () => { + expect(() => assertOutputLineLimit({ + markdown: 'a\nb\nc', + maxOutputLines: 2, + hasTitleFilter: false, + stage: 'final markdown', + })).toThrow('--filter-title-block-id'); + }); + + it('does not apply the limit when a title filter is present', () => { + expect(() => assertOutputLineLimit({ + markdown: 'a\nb\nc', + maxOutputLines: 2, + hasTitleFilter: true, + stage: 'final markdown', + })).not.toThrow(); + }); +}); + +describe('buildFilterErrorMessage', () => { + it('prints available headings in compact text format', () => { + const msg = buildFilterErrorMessage( + { appId: '', appSecret: '', url: 'https://example.feishu.cn/docx/doc', filterTitle: 'Missing' } as any, + { + blocks: [], + matched: false, + availableHeadings: [ + h('h1', 1, 'A'), + h('h2', 2, 'B'), + ], + }, + 'https://example.feishu.cn/docx/doc', + 'doc', + ); + + expect(msg).toContain('Full title list of the document:\n\n---\nformat: lark-docx2md.titles'); + expect(msg).toContain('npx -y lark-docx2md@latest dl --filter-title-block-id "<blockId>" --url "<url>"'); + expect(msg).toContain('# [h1] A\n## [h2] B\n'); + expect(msg).not.toContain('blockId:'); + expect(msg).not.toContain('titles:'); + }); +}); diff --git a/tests/logger.test.ts b/tests/logger.test.ts index 04e23d3..2ad2a57 100644 --- a/tests/logger.test.ts +++ b/tests/logger.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { createLogger, setLogLevel } from '../src/logger.js'; +import { createLogger, setLogLevel } from '../src/lib/logger.js'; import { LoggerLevel } from '@larksuiteoapi/node-sdk'; describe('logger', () => { diff --git a/tests/md-ast/parser.test.ts b/tests/md-ast/parser.test.ts index e46e515..2bfe9c4 100644 --- a/tests/md-ast/parser.test.ts +++ b/tests/md-ast/parser.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { Parser } from '../../src/md-ast/parser.js'; import { registerBuiltinParsers } from '../../src/md-ast/parsers/index.js'; import { MdSerializer, registerBuiltinSerializers } from '../../src/md-ast/serializer.js'; -import type { DocxBlock, DocInfo } from '../../src/types.js'; +import type { DocxBlock, DocInfo } from '../../src/lib/types.js'; // ─── Helpers ──────────────────────────────────────────────────────────────── diff --git a/tests/md-ast/transformer.test.ts b/tests/md-ast/transformer.test.ts index d1b4aec..cb3f65b 100644 --- a/tests/md-ast/transformer.test.ts +++ b/tests/md-ast/transformer.test.ts @@ -4,8 +4,8 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { MdTransformer } from '../../src/md-ast/transformer.js'; import type { MdBlockNode } from '../../src/md-ast/types.js'; -import type { ConvertOptions } from '../../src/types.js'; -import type { LarkClient } from '../../src/client.js'; +import type { ConvertOptions } from '../../src/lib/types.js'; +import type { LarkClient } from '../../src/lib/client.js'; // ─── helpers ───────────────────────────────────────────────────────────── diff --git a/tests/title-filter.test.ts b/tests/title-filter.test.ts index 96dc3eb..eef1988 100644 --- a/tests/title-filter.test.ts +++ b/tests/title-filter.test.ts @@ -5,8 +5,8 @@ import { createTitleFilter, extractHeadingText, getHeadingLevel, -} from '../src/title-filter.js'; -import type { DocxBlock } from '../src/types.js'; +} from '../src/lib/title-filter.js'; +import type { DocxBlock } from '../src/lib/types.js'; // ─── Helper: 创建模拟 DocxBlock ───────────────────────────────────────────── diff --git a/tsdown.config.ts b/tsdown.config.ts index 8380cd7..8630913 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsdown' export default defineConfig({ - entry: ['src/cli.ts', 'src/converter.ts'], + entry: { cli: 'src/cli.ts', converter: 'src/lib/converter.ts' }, format: ['esm'], outDir: 'dist', clean: true,