botrun-horse 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +1 -0
  2. package/bin/bh.mjs +193 -0
  3. package/bin/commands/dag-cmd.mjs +74 -0
  4. package/bin/commands/db-cmd.mjs +73 -0
  5. package/bin/commands/doc.mjs +185 -0
  6. package/bin/commands/gemini.mjs +120 -0
  7. package/bin/commands/help.mjs +109 -0
  8. package/bin/commands/legal.mjs +174 -0
  9. package/bin/commands/nchc.mjs +212 -0
  10. package/bin/commands/openrouter.mjs +154 -0
  11. package/bin/commands/prompt.mjs +175 -0
  12. package/bin/commands/schema.mjs +258 -0
  13. package/bin/commands/search.mjs +46 -0
  14. package/bin/commands/writing.mjs +33 -0
  15. package/lib/core/adapters/base.mjs +52 -0
  16. package/lib/core/adapters/claude.mjs +13 -0
  17. package/lib/core/adapters/gemini-api.mjs +174 -0
  18. package/lib/core/adapters/gemini-shared.mjs +164 -0
  19. package/lib/core/adapters/gemini-vertex.mjs +232 -0
  20. package/lib/core/adapters/local.mjs +13 -0
  21. package/lib/core/adapters/nchc.mjs +236 -0
  22. package/lib/core/adapters/openai-shared.mjs +34 -0
  23. package/lib/core/adapters/openrouter.mjs +304 -0
  24. package/lib/core/ai-cache.mjs +277 -0
  25. package/lib/core/ai-router.mjs +217 -0
  26. package/lib/core/cli-utils.mjs +170 -0
  27. package/lib/core/dag.mjs +114 -0
  28. package/lib/core/db.mjs +412 -0
  29. package/lib/core/env.mjs +64 -0
  30. package/lib/core/llm.mjs +58 -0
  31. package/lib/core/paths.mjs +115 -0
  32. package/lib/core/proxy.mjs +46 -0
  33. package/lib/core/watermelon.mjs +9 -0
  34. package/lib/doc/index.mjs +419 -0
  35. package/lib/doc/office2text.mjs +234 -0
  36. package/lib/doc/pdf2text.mjs +133 -0
  37. package/lib/doc/split.mjs +132 -0
  38. package/lib/flows/draft-writing.mjs +29 -0
  39. package/lib/flows/gemini-ask.mjs +185 -0
  40. package/lib/flows/hatch-portal.mjs +13 -0
  41. package/lib/flows/legal-ask.mjs +325 -0
  42. package/lib/flows/openai-agent.mjs +167 -0
  43. package/lib/flows/opencode-agent.mjs +240 -0
  44. package/lib/flows/openrouter-ask.mjs +111 -0
  45. package/lib/flows/review-doc.mjs +18 -0
  46. package/lib/ocr/index.mjs +6 -0
  47. package/lib/portal/hatch.mjs +6 -0
  48. package/lib/portal/index.mjs +6 -0
  49. package/lib/prompt/prompt-search.mjs +55 -0
  50. package/lib/prompt/prompt-store.mjs +94 -0
  51. package/lib/prompt/prompts/zero-framework/coding.md +15 -0
  52. package/lib/prompt/prompts/zero-framework/search.md +12 -0
  53. package/lib/prompt/prompts/zero-framework/slice.md +11 -0
  54. package/lib/search/crawler.mjs +6 -0
  55. package/lib/search/index.mjs +7 -0
  56. package/lib/tools/fs-tools.mjs +268 -0
  57. package/lib/tools/index.mjs +27 -0
  58. package/lib/writing/generate.mjs +86 -0
  59. package/lib/writing/generators/nstc-generators.mjs +279 -0
  60. package/lib/writing/generators/nstc-top5.mjs +554 -0
  61. package/lib/writing/index.mjs +5 -0
  62. package/lib/writing/layouts/nstc-layout.mjs +249 -0
  63. package/lib/writing/renderer.mjs +61 -0
  64. package/package.json +35 -0
@@ -0,0 +1,234 @@
1
+ // lib/doc/office2text.mjs — Office 全系列文件轉文字
2
+ //
3
+ // 核心設計(方案 E 混合最佳化):
4
+ // LibreOffice --headless --convert-to txt:Text 直接輸出純文字,
5
+ // 跳過「先轉 PDF 再用 pdftotext」的中間步驟,更快、更省資源。
6
+ //
7
+ // 頁面分割:LibreOffice 無法直接輸出 page-by-page 文字,
8
+ // 改用換頁符號(\f,ASCII 12)分割頁面。
9
+ // 此方法對 Writer/Impress 文件效果良好;
10
+ // Calc 試算表則以每個 sheet 作為一頁。
11
+ //
12
+ // 支援格式:
13
+ // Writer: .doc .docx .odt .rtf .odm
14
+ // Impress: .ppt .pptx .odp
15
+ // Calc: .xls .xlsx .ods .csv
16
+ //
17
+ // 平台需求:libreoffice 或 soffice 在 PATH 中
18
+ // Ubuntu/Debian: apt install libreoffice
19
+ // macOS: brew install --cask libreoffice
20
+ // Docker: ghcr.io/jlesage/libreoffice
21
+
22
+ import { execFile } from 'node:child_process';
23
+ import { promisify } from 'node:util';
24
+ import { mkdtemp, rm, readdir, readFile } from 'node:fs/promises';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+ import { toolBin } from '../core/paths.mjs';
28
+
29
+ const execFileAsync = promisify(execFile);
30
+
31
+ /** 支援的 Office 副檔名(小寫) */
32
+ const OFFICE_EXTENSIONS = new Set([
33
+ '.doc', '.docx', '.odt', '.rtf', '.odm', '.fodt',
34
+ '.ppt', '.pptx', '.odp', '.fodp',
35
+ '.xls', '.xlsx', '.ods', '.fods', '.csv',
36
+ ]);
37
+
38
+ /** 純文字副檔名(小寫) */
39
+ const TEXT_EXTENSIONS = new Set(['.txt', '.text', '.md', '.markdown', '.rst', '.log']);
40
+
41
+ /**
42
+ * 判斷檔案是否為支援的 Office 格式
43
+ *
44
+ * @param {string} filePath - 檔案路徑
45
+ * @returns {boolean}
46
+ */
47
+ export function isOfficeFile(filePath) {
48
+ return OFFICE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
49
+ }
50
+
51
+ /**
52
+ * 判斷檔案是否為純文字格式(.txt / .md / .rst 等)
53
+ *
54
+ * @param {string} filePath - 檔案路徑
55
+ * @returns {boolean}
56
+ */
57
+ export function isTextFile(filePath) {
58
+ return TEXT_EXTENSIONS.has(path.extname(filePath).toLowerCase());
59
+ }
60
+
61
+ /**
62
+ * 將純文字檔案轉為逐頁文字陣列
63
+ *
64
+ * 分頁策略:
65
+ * - 若包含換頁符號(\f)→ 依 \f 分頁
66
+ * - 否則以每 N 行為一頁(預設 80 行/頁,近似 A4 文字頁)
67
+ *
68
+ * @param {string} filePath - 純文字檔案路徑
69
+ * @param {number} linesPerPage - 每頁行數(預設 80)
70
+ * @returns {Promise<Array<{page: number, text: string}>>}
71
+ */
72
+ export async function extractTextFilePages(filePath, linesPerPage = 80) {
73
+ const { readFile } = await import('node:fs/promises');
74
+ const content = await readFile(path.resolve(filePath), 'utf-8');
75
+
76
+ // 優先使用換頁符號分頁
77
+ if (content.includes('\f')) {
78
+ const rawPages = content.split('\f');
79
+ return rawPages
80
+ .map((text, idx) => ({ page: idx + 1, text: text.trim() }))
81
+ .filter(p => p.text.length > 0);
82
+ }
83
+
84
+ // 否則以行數分頁
85
+ const lines = content.split('\n');
86
+ if (lines.length <= linesPerPage) {
87
+ return [{ page: 1, text: content.trim() }];
88
+ }
89
+
90
+ const pages = [];
91
+ for (let i = 0; i < lines.length; i += linesPerPage) {
92
+ const pageText = lines.slice(i, i + linesPerPage).join('\n').trim();
93
+ if (pageText.length > 0) {
94
+ pages.push({ page: Math.floor(i / linesPerPage) + 1, text: pageText });
95
+ }
96
+ }
97
+ return pages.length > 0 ? pages : [{ page: 1, text: content.trim() }];
98
+ }
99
+
100
+ /**
101
+ * 取得 libreoffice 執行檔路徑
102
+ * 依序嘗試 libreoffice、soffice(舊版 LibreOffice 可執行檔名稱)
103
+ *
104
+ * @returns {string} libreoffice 執行檔路徑
105
+ */
106
+ function getLoBin() {
107
+ // 先嘗試 libreoffice,再嘗試 soffice
108
+ const lo = toolBin('libreoffice');
109
+ if (lo !== 'libreoffice') return lo;
110
+ return toolBin('soffice', 'libreoffice');
111
+ }
112
+
113
+ /**
114
+ * 呼叫 LibreOffice headless 轉換單一文件
115
+ *
116
+ * @param {string} absPath - 輸入檔案絕對路徑
117
+ * @param {string} format - 目標格式('txt:Text' | 'pdf')
118
+ * @param {string} outDir - 輸出目錄
119
+ * @param {number} timeout - 逾時毫秒(預設 120 秒)
120
+ */
121
+ async function loConvert(absPath, format, outDir, timeout = 120000) {
122
+ const lo = getLoBin();
123
+ await execFileAsync(lo, [
124
+ '--headless',
125
+ '--convert-to', format,
126
+ '--outdir', outDir,
127
+ absPath,
128
+ ], {
129
+ timeout,
130
+ maxBuffer: 32 * 1024 * 1024, // 32MB stdout buffer
131
+ });
132
+ }
133
+
134
+ /**
135
+ * 將 Office 文件轉為純文字(單一字串)
136
+ *
137
+ * @param {string} filePath - Office 文件路徑
138
+ * @returns {Promise<string>} 文字內容
139
+ */
140
+ export async function extractOfficeText(filePath) {
141
+ const absPath = path.resolve(filePath);
142
+ const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'bh-office-'));
143
+
144
+ try {
145
+ await loConvert(absPath, 'txt:Text', tmpDir);
146
+
147
+ // LibreOffice 輸出檔名 = 原始檔名(去副檔名)+ .txt
148
+ const baseName = path.basename(absPath, path.extname(absPath));
149
+ const txtPath = path.join(tmpDir, `${baseName}.txt`);
150
+ const content = await readFile(txtPath, 'utf-8');
151
+ return content;
152
+ } finally {
153
+ await rm(tmpDir, { recursive: true, force: true });
154
+ }
155
+ }
156
+
157
+ /**
158
+ * 將 Office 文件轉為逐頁文字陣列
159
+ *
160
+ * 分頁策略:
161
+ * - Writer/Impress: LibreOffice 在輸出文字中插入換頁符號(\f,ASCII 12)
162
+ * - Calc: 先轉 PDF,再用 pdftotext 按頁萃取(保留 sheet 分頁)
163
+ *
164
+ * @param {string} filePath - Office 文件路徑
165
+ * @returns {Promise<Array<{page: number, text: string}>>} 逐頁文字陣列
166
+ */
167
+ export async function extractOfficePages(filePath) {
168
+ const absPath = path.resolve(filePath);
169
+ const ext = path.extname(absPath).toLowerCase();
170
+
171
+ // Calc 類型:先轉 PDF,再用 pdftotext 按頁萃取
172
+ const calcExts = new Set(['.xls', '.xlsx', '.ods', '.fods', '.csv']);
173
+ if (calcExts.has(ext)) {
174
+ return extractCalcPages(absPath);
175
+ }
176
+
177
+ // Writer / Impress 類型:直接轉 txt,用換頁符號分割
178
+ return extractWriterImpressPages(absPath);
179
+ }
180
+
181
+ /**
182
+ * Writer/Impress 逐頁萃取
183
+ * LibreOffice 輸出的 txt 中,頁面邊界為 \f(換頁符號,ASCII 12)
184
+ */
185
+ async function extractWriterImpressPages(absPath) {
186
+ const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'bh-office-'));
187
+
188
+ try {
189
+ await loConvert(absPath, 'txt:Text', tmpDir);
190
+
191
+ const baseName = path.basename(absPath, path.extname(absPath));
192
+ const txtPath = path.join(tmpDir, `${baseName}.txt`);
193
+ const content = await readFile(txtPath, 'utf-8');
194
+
195
+ // 以換頁符號(\f)分割頁面
196
+ const rawPages = content.split('\f');
197
+
198
+ // 清理頁面文字(去除首尾空白,過濾空頁)
199
+ const pages = rawPages
200
+ .map((text, idx) => ({ page: idx + 1, text: text.trim() }))
201
+ .filter(p => p.text.length > 0);
202
+
203
+ // 若無換頁符號(如純文字 .txt),整份文件視為第 1 頁
204
+ if (pages.length === 0) {
205
+ return [{ page: 1, text: content.trim() }];
206
+ }
207
+
208
+ return pages;
209
+ } finally {
210
+ await rm(tmpDir, { recursive: true, force: true });
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Calc (試算表) 逐頁萃取
216
+ * 先轉 PDF(LibreOffice 會保留頁碼),再用 pdftotext 按頁萃取
217
+ */
218
+ async function extractCalcPages(absPath) {
219
+ const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'bh-office-'));
220
+
221
+ try {
222
+ await loConvert(absPath, 'pdf', tmpDir);
223
+
224
+ const baseName = path.basename(absPath, path.extname(absPath));
225
+ const pdfPath = path.join(tmpDir, `${baseName}.pdf`);
226
+
227
+ // 動態匯入 pdf2text,避免循環依賴
228
+ const { extractAllPages } = await import('./pdf2text.mjs');
229
+ const pages = await extractAllPages(pdfPath);
230
+ return pages;
231
+ } finally {
232
+ await rm(tmpDir, { recursive: true, force: true });
233
+ }
234
+ }
@@ -0,0 +1,133 @@
1
+ // lib/doc/pdf2text.mjs — PDF 轉純文字模組
2
+ //
3
+ // 核心設計(方案 E 混合最佳化):
4
+ // 直接從原始 PDF 按頁萃取,不需要先拆頁成獨立 PDF 檔案。
5
+ // pdftotext -f {page} -l {page} -layout -enc UTF-8 {input} -
6
+ // stdout 輸出,支援 Unix pipe。
7
+ //
8
+ // 平行策略:
9
+ // - 單一 PDF 的所有頁面以 Promise.all 平行萃取(I/O bound)
10
+ // - 多個 PDF 的批次平行由上層(ingestBatch)控制 concurrency
11
+
12
+ import { execFile } from 'node:child_process';
13
+ import { promisify } from 'node:util';
14
+ import { toolBin } from '../core/paths.mjs';
15
+
16
+ const execFileAsync = promisify(execFile);
17
+
18
+ /**
19
+ * 取得 PDF 總頁數
20
+ * 使用 pdfinfo 解析 "Pages: N" 輸出
21
+ *
22
+ * @param {string} inputPath - PDF 檔案路徑
23
+ * @returns {Promise<number>} 總頁數
24
+ */
25
+ export async function getPageCount(inputPath) {
26
+ const pdfinfo = toolBin('pdfinfo');
27
+ const { stdout } = await execFileAsync(pdfinfo, [inputPath]);
28
+ const match = stdout.match(/^Pages:\s+(\d+)/m);
29
+ if (!match) {
30
+ throw new Error(`無法從 pdfinfo 輸出中解析總頁數:${inputPath}`);
31
+ }
32
+ return parseInt(match[1], 10);
33
+ }
34
+
35
+ /**
36
+ * 萃取指定頁面的文字內容
37
+ *
38
+ * 直接從原始 PDF 萃取,不需要先 split:
39
+ * pdftotext -f {page} -l {page} -layout -enc UTF-8 {input} -
40
+ *
41
+ * -layout 保留原始排版佈局(段落、縮排)
42
+ * -enc UTF-8 確保繁體中文、日文、韓文等 CJK 字符正確輸出
43
+ *
44
+ * @param {string} inputPath - PDF 檔案路徑
45
+ * @param {number} pageNum - 頁碼(從 1 開始)
46
+ * @returns {Promise<{page: number, text: string}>} 該頁的文字資料
47
+ */
48
+ export async function extractPage(inputPath, pageNum) {
49
+ const pdftotext = toolBin('pdftotext');
50
+ const args = [
51
+ '-f', String(pageNum), // 起始頁
52
+ '-l', String(pageNum), // 結束頁(與起始頁相同 = 單頁)
53
+ '-layout', // 保留原始排版佈局
54
+ '-enc', 'UTF-8', // 強制 UTF-8 輸出(繁體中文必要)
55
+ '-nopgbrk', // 不在頁尾加分頁符號(AI 友善)
56
+ inputPath, // 輸入 PDF 檔案
57
+ '-', // 輸出到 stdout
58
+ ];
59
+
60
+ const { stdout } = await execFileAsync(pdftotext, args, {
61
+ maxBuffer: 64 * 1024 * 1024, // 64MB,支援大型頁面
62
+ });
63
+
64
+ return {
65
+ page: pageNum,
66
+ text: stdout,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * 萃取 PDF 所有頁面的文字內容(平行執行)
72
+ *
73
+ * 所有頁面同時發出 pdftotext 子程序(I/O bound,平行效益高)。
74
+ * 若頁數極多(>50頁),建議呼叫方改用 extractPagesBatched 控制上限。
75
+ *
76
+ * @param {string} inputPath - PDF 檔案路徑
77
+ * @returns {Promise<Array<{page: number, text: string}>>} 各頁文字資料陣列
78
+ */
79
+ export async function extractAllPages(inputPath) {
80
+ const totalPages = await getPageCount(inputPath);
81
+ const promises = [];
82
+ for (let i = 1; i <= totalPages; i++) {
83
+ promises.push(extractPage(inputPath, i));
84
+ }
85
+ const results = await Promise.all(promises);
86
+ return results;
87
+ }
88
+
89
+ /**
90
+ * 萃取 PDF 所有頁面文字(可設定最大並發數)
91
+ *
92
+ * 適用於頁數很多的大型 PDF,避免同時開啟過多子程序。
93
+ * 類似 GNU parallel 的 --jobs N 設計。
94
+ *
95
+ * @param {string} inputPath - PDF 檔案路徑
96
+ * @param {number} concurrency - 最大並發頁面數(預設 10)
97
+ * @returns {Promise<Array<{page: number, text: string}>>} 各頁文字資料陣列
98
+ */
99
+ export async function extractAllPagesConcurrent(inputPath, concurrency = 10) {
100
+ const totalPages = await getPageCount(inputPath);
101
+ const results = new Array(totalPages);
102
+
103
+ // Semaphore 實作(類 GNU parallel -j N)
104
+ let running = 0;
105
+ let next = 0;
106
+
107
+ await new Promise((resolve, reject) => {
108
+ function schedule() {
109
+ while (running < concurrency && next < totalPages) {
110
+ const pageNum = next + 1;
111
+ const idx = next;
112
+ next++;
113
+ running++;
114
+
115
+ extractPage(inputPath, pageNum)
116
+ .then(r => {
117
+ results[idx] = r;
118
+ running--;
119
+ if (next < totalPages) {
120
+ schedule();
121
+ } else if (running === 0) {
122
+ resolve();
123
+ }
124
+ })
125
+ .catch(err => reject(err));
126
+ }
127
+ if (next >= totalPages && running === 0) resolve();
128
+ }
129
+ schedule();
130
+ });
131
+
132
+ return results;
133
+ }
@@ -0,0 +1,132 @@
1
+ // lib/doc/split.mjs — PDF 拆頁模組
2
+ //
3
+ // 核心設計(方案 E):
4
+ // 「轉文字」不需要先拆頁 — pdftotext -f/-l 直接按頁萃取。
5
+ // 本模組的拆頁功能僅在需要「頁面級別獨立 PDF 檔案」時使用,
6
+ // 例如:提供給 LLM 做圖像辨識、或作為頁面精準引證的附件。
7
+ //
8
+ // 工具:qpdf(跨平台,比 pdfseparate 更能精確控制輸出檔名格式)
9
+ // 平行策略:所有頁面同時以 Promise.all 平行拆分(I/O bound)
10
+ // 命名格式:{原始檔名}_page_{三位數頁碼}.pdf(如 report_page_001.pdf)
11
+
12
+ import { execFile } from 'node:child_process';
13
+ import { promisify } from 'node:util';
14
+ import { mkdir } from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { toolBin } from '../core/paths.mjs';
17
+
18
+ const execFileAsync = promisify(execFile);
19
+
20
+ /**
21
+ * 取得 PDF 總頁數(使用 qpdf --show-npages)
22
+ *
23
+ * @param {string} inputPath - PDF 檔案路徑
24
+ * @returns {Promise<number>} 總頁數
25
+ */
26
+ export async function getPdfPageCount(inputPath) {
27
+ const qpdf = toolBin('qpdf');
28
+ const absInput = path.resolve(inputPath);
29
+
30
+ const { stdout } = await execFileAsync(qpdf, ['--show-npages', absInput]);
31
+ const count = parseInt(stdout.trim(), 10);
32
+
33
+ if (Number.isNaN(count) || count <= 0) {
34
+ throw new Error(`無法解析頁數,qpdf 輸出: "${stdout.trim()}"`);
35
+ }
36
+
37
+ return count;
38
+ }
39
+
40
+ /**
41
+ * 拆分單頁 PDF(內部用)
42
+ *
43
+ * @param {string} qpdf - qpdf 執行檔路徑
44
+ * @param {string} absInput - 來源 PDF 絕對路徑
45
+ * @param {number} page - 頁碼
46
+ * @param {string} outputPath - 輸出 PDF 絕對路徑
47
+ */
48
+ async function splitOnePage(qpdf, absInput, page, outputPath) {
49
+ await execFileAsync(qpdf, [
50
+ absInput,
51
+ '--pages', '.', String(page),
52
+ '--',
53
+ outputPath,
54
+ ]);
55
+ }
56
+
57
+ /**
58
+ * 將多頁 PDF 拆為單頁獨立 PDF(平行執行)
59
+ *
60
+ * 所有頁面同時啟動 qpdf 子程序(I/O bound,CPU 佔用低)。
61
+ * 類似 GNU parallel 的 --jobs 0(使用所有可用核心)設計。
62
+ *
63
+ * 輸出格式:{原始檔名}_page_{三位數頁碼}.pdf
64
+ * 例如: report_page_001.pdf, report_page_012.pdf, report_page_100.pdf
65
+ *
66
+ * @param {string} inputPath - 來源 PDF 檔案路徑
67
+ * @param {string} outputDir - 輸出目錄路徑(不存在時自動建立)
68
+ * @param {object} [opts]
69
+ * @param {number} [opts.concurrency] - 最大並發數(預設不限,全部平行)
70
+ * @returns {Promise<Array<{ page: number, path: string }>>} 拆頁結果陣列
71
+ */
72
+ export async function splitPdf(inputPath, outputDir, opts = {}) {
73
+ const qpdf = toolBin('qpdf');
74
+ const absInput = path.resolve(inputPath);
75
+ const absOutputDir = path.resolve(outputDir);
76
+
77
+ await mkdir(absOutputDir, { recursive: true });
78
+
79
+ const totalPages = await getPdfPageCount(absInput);
80
+ const baseName = path.basename(absInput, '.pdf');
81
+
82
+ // 預建所有輸出路徑
83
+ const tasks = [];
84
+ for (let page = 1; page <= totalPages; page++) {
85
+ const pageStr = String(page).padStart(3, '0');
86
+ const outputPath = path.join(absOutputDir, `${baseName}_page_${pageStr}.pdf`);
87
+ tasks.push({ page, outputPath });
88
+ }
89
+
90
+ if (opts.concurrency && opts.concurrency > 0) {
91
+ // 有限並發(類 GNU parallel -j N)
92
+ const results = [];
93
+ let idx = 0;
94
+
95
+ await new Promise((resolve, reject) => {
96
+ let running = 0;
97
+
98
+ function schedule() {
99
+ while (running < opts.concurrency && idx < tasks.length) {
100
+ const { page, outputPath } = tasks[idx++];
101
+ running++;
102
+
103
+ splitOnePage(qpdf, absInput, page, outputPath)
104
+ .then(() => {
105
+ results.push({ page, path: outputPath });
106
+ running--;
107
+ if (idx < tasks.length) {
108
+ schedule();
109
+ } else if (running === 0) {
110
+ resolve();
111
+ }
112
+ })
113
+ .catch(reject);
114
+ }
115
+ if (idx >= tasks.length && running === 0) resolve();
116
+ }
117
+ schedule();
118
+ });
119
+
120
+ // 依頁碼排序(平行可能亂序)
121
+ results.sort((a, b) => a.page - b.page);
122
+ return results;
123
+ } else {
124
+ // 全部平行(無限並發,頁數少時效率最高)
125
+ const promises = tasks.map(({ page, outputPath }) =>
126
+ splitOnePage(qpdf, absInput, page, outputPath).then(() => ({ page, path: outputPath }))
127
+ );
128
+ const results = await Promise.all(promises);
129
+ results.sort((a, b) => a.page - b.page);
130
+ return results;
131
+ }
132
+ }
@@ -0,0 +1,29 @@
1
+ // lib/flows/draft-writing.mjs — 公文撰寫業務流程
2
+ // 串接:config → DAG → writing/generate → 更新狀態
3
+
4
+ import { loadDag, initState, saveState, loadState } from '../core/dag.mjs';
5
+ import { generateAll } from '../writing/generate.mjs';
6
+
7
+ /**
8
+ * 執行公文撰寫流程
9
+ * @param {object} opts
10
+ * @param {string} opts.project - 專案名稱
11
+ * @param {object} opts.generators - 公文類型 → 生成函式
12
+ * @param {object} [opts.overrides] - 按 ID 覆蓋的生成函式
13
+ * @param {number} [opts.concurrency=10]
14
+ * @param {number[]} [opts.ids] - 指定生成的文件 ID
15
+ */
16
+ export async function draftWriting(opts = {}) {
17
+ const { project = 'nstc', generators, overrides, concurrency = 10, ids = null } = opts;
18
+
19
+ // 確保 DAG 已初始化
20
+ let state = loadState(project);
21
+ if (!state) {
22
+ const { documents } = loadDag(project);
23
+ state = initState(documents);
24
+ saveState(state, project);
25
+ }
26
+
27
+ // 執行生成
28
+ return generateAll({ project, generators, overrides, concurrency, ids });
29
+ }