@xqyz/xq-cli 0.1.2 → 0.2.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,79 @@
1
+ # 版本迭代日志
2
+
3
+ 本项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/):
4
+
5
+ - `MAJOR`:存在不兼容变更。
6
+ - `MINOR`:向后兼容的新功能。
7
+ - `PATCH`:向后兼容的修复或维护更新。
8
+
9
+ 发布日期以 npm 官方仓库记录为准。发布前必须先更新本文件,再执行版本发布。
10
+
11
+ ## [Unreleased]
12
+
13
+ ## [0.2.0] - 2026-08-10
14
+
15
+ ### 新增
16
+
17
+ - 新增 `xq-cli directory view --cid <cid>`,通过只读目录树接口查看大纲续写后的完整目录。
18
+ - 支持完整目录的递归编号展示、节点统计、`--json` 结构化输出和 `--out <path>` UTF-8 JSON 导出。
19
+ - 新增 `xq-cli gallery-status`,调用与最新喜鹊前端一致的图库列表接口。
20
+ - 结构化返回 `available` 和 `totalRecords`,供 WorkBuddy 在展示“我的图库”前判断是否可选。
21
+
22
+ ### 文档
23
+
24
+ - 建立正式版本迭代日志。
25
+ - 增加 npm 发布、验证、清理和故障处理手册。
26
+ - 更新 CLI 帮助、README 和 OpenCLI 使用说明。
27
+
28
+ ### 维护
29
+
30
+ - 规范 npm `bin` 入口路径,避免发布时由 npm 自动纠正清单。
31
+ - 增加完整目录格式化与只读 API 集成测试。
32
+
33
+ ## [0.1.3] - 2026-08-07
34
+
35
+ ### 新增
36
+
37
+ - 新增 `xq-cli outline view --cid <cid>`,只读查看已有大纲,不会重新触发大纲生成。
38
+ - 支持通过 `--json` 返回完整大纲数据。
39
+ - 支持通过 `--out <path>` 导出可编辑的大纲 JSON。
40
+ - 新增 `xq-cli outline update --cid <cid> --file <file>`,从完整 JSON 文件更新大纲。
41
+ - 支持修改章节标题、主题、重点程度和章节顺序,以及新增、删除章节。
42
+ - 新增 `--dry-run`,在写入后台前展示新增、修改、删除、排序和重点程度差异。
43
+
44
+ ### 安全性
45
+
46
+ - 自动校验编辑文件中的任务 CID、章节 ID、重复 ID 和必填字段。
47
+ - 自动根据服务端当前大纲计算 `updateFlag`。
48
+ - 非交互环境必须传入 `--yes` 才能保存修改。
49
+ - 删除已有章节必须额外传入 `--allow-delete`。
50
+
51
+ ### 测试与文档
52
+
53
+ - 增加大纲差异处理单元测试和模拟 API 集成测试,共 7 项。
54
+ - 更新 CLI 帮助、README 和 OpenCLI 使用说明。
55
+
56
+ ## [0.1.2] - 2026-04-28
57
+
58
+ ### 变更
59
+
60
+ - 默认使用生产 API 地址 `https://ai.bidfile.qianlima.com/api`。
61
+ - 首次使用可直接执行 `xq-cli login`,不再要求每次显式提供 `--base-url`。
62
+ - 完善本地配置读取与默认值合并,兼容旧配置和配置文件异常场景。
63
+ - 更新生产环境和测试环境登录示例。
64
+
65
+ ## [0.1.1] - 2026-04-27
66
+
67
+ ### 变更
68
+
69
+ - 增加 npm 官方仓库安装和全局升级说明。
70
+ - 将登录示例从本地地址调整为生产环境地址。
71
+ - 清理示例中的真实账号信息,改为通用占位符。
72
+
73
+ ## [0.1.0] - 2026-04-27
74
+
75
+ ### 新增
76
+
77
+ - `@xqyz/xq-cli` 首次公开发布。
78
+ - 提供登录、任务初始化、文件解析、大纲生成、正文生成、状态查询和 Word 导出能力。
79
+ - 提供 `wizard` 全流程向导和生成配置选项查询。
package/README.md CHANGED
@@ -4,6 +4,11 @@ Standalone CLI project for xique brand task workflows.
4
4
 
5
5
  Project execution rules for agents live in [AGENTS.md](./AGENTS.md). That file defines the mandatory config-confirmation flow before any bid generation run.
6
6
 
7
+ Project maintenance documents:
8
+
9
+ - [Version changelog](./CHANGELOG.md)
10
+ - [Release process](./docs/RELEASING.md)
11
+
7
12
  ## Install from npm
8
13
 
9
14
  Install the published package from the public npm registry:
@@ -40,7 +45,7 @@ npm pack
40
45
  That command produces a `.tgz` tarball which can be shared and installed with:
41
46
 
42
47
  ```bash
43
- npm install -g .\xqyz-xq-cli-0.1.2.tgz
48
+ npm install -g .\xqyz-xq-cli-0.1.4.tgz
44
49
  ```
45
50
 
46
51
  ## Usage
@@ -54,6 +59,13 @@ xq-cli wizard --file D:\bids\tender.docx
54
59
  xq-cli init --file D:\bids\tender.docx --wait
55
60
  xq-cli outline --cid <cid> --interactive --wait
56
61
  xq-cli outline --cid <cid> --wait
62
+ xq-cli outline view --cid <cid>
63
+ xq-cli outline view --cid <cid> --out D:\output\outline.json
64
+ xq-cli outline update --cid <cid> --file D:\output\outline.json --dry-run --json
65
+ xq-cli outline update --cid <cid> --file D:\output\outline.json --yes
66
+ xq-cli directory view --cid <cid>
67
+ xq-cli directory view --cid <cid> --out D:\output\directory.json
68
+ xq-cli gallery-status --json
57
69
  xq-cli write --cid <cid> --wait
58
70
  xq-cli status --cid <cid> --content --json
59
71
  xq-cli export --cid <cid> --interactive --out D:\output
@@ -64,6 +76,13 @@ Fresh installs default to the production API address `https://ai.bidfile.qianlim
64
76
 
65
77
  `login` now defaults to browser authorization. If you explicitly pass `--name` and `--password`, the CLI falls back to password login.
66
78
 
79
+ `directory view` reads the complete generated directory tree without triggering outline, directory, or content generation. Add `--json` for the raw API structure, or `--out <file-or-directory>` to save a UTF-8 JSON document.
80
+
81
+ `gallery-status` checks the same `/image/library/query` endpoint as the latest
82
+ frontend. `available=true` means “我的图库” contains at least one image and may
83
+ be enabled for generation; an empty gallery remains unavailable until the user
84
+ uploads an image in the Xique frontend.
85
+
67
86
  When `outline --wait` or `write --wait` hits a required response-basis step, the CLI will pause and ask you to choose `0/1/2` before continuing. It no longer auto-selects a default when you just press Enter.
68
87
 
69
88
  `write --wait` now also retries `sequelForOutline` when the directory phase stalls at the outline-complete step, and auto-calls `generateAllUnfinished` if the backend reports `100%` but still leaves unfinished chapters behind.
@@ -72,6 +91,43 @@ When `outline --wait` or `write --wait` hits a required response-basis step, the
72
91
 
73
92
  `order/charge -> c/viewStep(101) -> task/autoGenerateContent`
74
93
 
94
+ ## View and update an existing outline
95
+
96
+ View the complete current outline without triggering outline generation:
97
+
98
+ ```bash
99
+ xq-cli outline view --cid <cid>
100
+ xq-cli outline view --cid <cid> --json
101
+ ```
102
+
103
+ Export an editable JSON copy:
104
+
105
+ ```bash
106
+ xq-cli outline view --cid <cid> --out D:\output\outline.json
107
+ ```
108
+
109
+ Edit the `outLine` array in that file. Keep an existing chapter's `id`, change `text`, `theme`, or `important`, reorder the array to reorder chapters, add a chapter without an `id`, and remove a chapter from the array to delete it. `important` accepts `1` (key chapter) or `2` (normal chapter).
110
+
111
+ Always preview the server-side diff first:
112
+
113
+ ```bash
114
+ xq-cli outline update --cid <cid> --file D:\output\outline.json --dry-run
115
+ ```
116
+
117
+ Apply after reviewing the preview:
118
+
119
+ ```bash
120
+ xq-cli outline update --cid <cid> --file D:\output\outline.json --yes
121
+ ```
122
+
123
+ Deleting existing chapters has an additional protection flag:
124
+
125
+ ```bash
126
+ xq-cli outline update --cid <cid> --file D:\output\outline.json --yes --allow-delete
127
+ ```
128
+
129
+ The update command automatically calculates `updateFlag` by comparing the edited file with the latest server outline. Changes to chapter titles or themes may cause the backend to regenerate affected child directories asynchronously.
130
+
75
131
  ## Interactive menus
76
132
 
77
133
  Use the full wizard if you want a CLI flow close to the frontend configuration panel:
package/package.json CHANGED
@@ -1,18 +1,20 @@
1
1
  {
2
2
  "name": "@xqyz/xq-cli",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for xique bid-book task workflows",
5
5
  "type": "module",
6
6
  "scripts": {
7
- "pack": "npm pack"
7
+ "pack": "npm pack",
8
+ "test": "node --test"
8
9
  },
9
10
  "bin": {
10
- "xq-cli": "./bin/xq-cli.js"
11
+ "xq-cli": "bin/xq-cli.js"
11
12
  },
12
13
  "files": [
13
14
  "bin",
14
15
  "src",
15
- "README.md"
16
+ "README.md",
17
+ "CHANGELOG.md"
16
18
  ],
17
19
  "engines": {
18
20
  "node": ">=18"
package/src/cli.mjs CHANGED
@@ -12,6 +12,17 @@ import axios from 'axios';
12
12
  import FormData from 'form-data';
13
13
  import { templateData } from './templateData.mjs';
14
14
  import { AI_PURPOSE_MAP, detectFileType } from './fileTypeConfig.mjs';
15
+ import {
16
+ buildEditableOutlineDocument,
17
+ extractOutlineRows,
18
+ formatOutlineDiffLines,
19
+ formatOutlineLines,
20
+ prepareOutlineUpdate,
21
+ } from './outline-edit.mjs';
22
+ import {
23
+ buildDirectoryViewDocument,
24
+ formatDirectoryLines,
25
+ } from './directory-view.mjs';
15
26
 
16
27
  const APP_DIR = path.join(os.homedir(), '.xq-opencli');
17
28
  const CONFIG_FILE = path.join(APP_DIR, 'config.json');
@@ -328,7 +339,29 @@ async function main() {
328
339
  await handleParseStatus(parsed, state);
329
340
  return;
330
341
  case 'outline':
331
- await handleOutline(parsed, state);
342
+ if (isOutlineAction(parsed, ['view', 'show'])) {
343
+ await handleOutlineView(parsed, state);
344
+ } else if (isOutlineAction(parsed, ['update', 'edit'])) {
345
+ await handleOutlineUpdate(parsed, state);
346
+ } else {
347
+ await handleOutline(parsed, state);
348
+ }
349
+ return;
350
+ case 'outline-view':
351
+ await handleOutlineView(parsed, state);
352
+ return;
353
+ case 'outline-update':
354
+ await handleOutlineUpdate(parsed, state);
355
+ return;
356
+ case 'directory':
357
+ if (!parsed._[1] || isOutlineAction(parsed, ['view', 'show'])) {
358
+ await handleDirectoryView(parsed, state);
359
+ } else {
360
+ throw new Error(`Unknown directory action: ${parsed._[1]}`);
361
+ }
362
+ return;
363
+ case 'directory-view':
364
+ await handleDirectoryView(parsed, state);
332
365
  return;
333
366
  case 'write':
334
367
  await handleWrite(parsed, state);
@@ -342,6 +375,9 @@ async function main() {
342
375
  case 'choices':
343
376
  handleChoices(parsed);
344
377
  return;
378
+ case 'gallery-status':
379
+ await handleGalleryStatus(parsed, state);
380
+ return;
345
381
  case 'wizard':
346
382
  await handleWizard(parsed, state);
347
383
  return;
@@ -359,9 +395,13 @@ Commands
359
395
  init Upload files and initialize a task
360
396
  parse-status Query or wait for parse completion
361
397
  outline Pre-set task params and trigger outline generation
398
+ outline view View the current outline without triggering generation
399
+ outline update Update the current outline from an edited JSON file
400
+ directory view View the complete generated directory tree without triggering generation
362
401
  write Charge if needed, enter content phase, and trigger content generation
363
402
  status Query task status
364
403
  choices Print selectable outline/export config values
404
+ gallery-status Check whether My Gallery contains selectable images
365
405
  wizard Run init -> outline -> write -> export with interactive menus
366
406
  export Download the generated docx
367
407
 
@@ -373,6 +413,7 @@ Examples
373
413
  xq-cli parse-status --cid <cid> --uuid <uuid> --wait
374
414
  xq-cli choices
375
415
  xq-cli choices outline --json
416
+ xq-cli gallery-status --json
376
417
  xq-cli wizard
377
418
  xq-cli wizard --file D:\\bids\\tender.docx
378
419
  xq-cli outline --cid <cid> --wait
@@ -382,6 +423,12 @@ Examples
382
423
  xq-cli outline --cid <cid> --wait --ignore-missing-bill
383
424
  xq-cli outline --cid <cid> --wait --reference-type 0
384
425
  xq-cli outline --cid <cid> --page-scope 3 --base expert --theme-style 4 --table-quantity rich --plan-mode quick --wait
426
+ xq-cli outline view --cid <cid>
427
+ xq-cli outline view --cid <cid> --out D:\\output\\outline.json
428
+ xq-cli outline update --cid <cid> --file D:\\output\\outline.json --dry-run --json
429
+ xq-cli outline update --cid <cid> --file D:\\output\\outline.json --yes
430
+ xq-cli directory view --cid <cid>
431
+ xq-cli directory view --cid <cid> --out D:\\output\\directory.json
385
432
  xq-cli write --cid <cid> --type 1 --wait
386
433
  xq-cli status --cid <cid> --content
387
434
  xq-cli export --cid <cid> --interactive --out D:\\output\\
@@ -396,6 +443,9 @@ Common options
396
443
  --reference-type <0|1|2> Outline reference choice: 0=merge, 1=format only, 2=score only
397
444
  --browser Force browser authorization login; browser is the default when credentials are not provided
398
445
  --interactive Open prompt menus for the current command
446
+ --dry-run Preview an outline update without saving it
447
+ --yes Confirm an outline update without an interactive prompt
448
+ --allow-delete Allow an outline update that removes existing chapters
399
449
  --ignore-short-requirement Follow the frontend ignore/continue path for requirement_judgement=0
400
450
  --ignore-missing-bill Follow the frontend ignore/continue path for billOfQuantities_judgement=0
401
451
 
@@ -443,6 +493,11 @@ Export style
443
493
  `.trim());
444
494
  }
445
495
 
496
+ function isOutlineAction(args, actions) {
497
+ const action = String(args?._?.[1] || '').trim().toLowerCase();
498
+ return actions.includes(action);
499
+ }
500
+
446
501
  async function handleLogin(args, state) {
447
502
  const baseUrl = normalizeBaseUrl(stringOption(args, 'baseUrl', state.baseUrl));
448
503
  if (!baseUrl) {
@@ -632,6 +687,29 @@ function handleChoices(args) {
632
687
  return payload;
633
688
  }
634
689
 
690
+ async function handleGalleryStatus(args, state) {
691
+ const client = createAuthorizedClient(args, state);
692
+ const response = await postJson(client, '/image/library/query', {
693
+ pageSize: 1,
694
+ pageNum: 1,
695
+ });
696
+ const rawTotal = Number(response?.data?.pageModel?.totalRecords ?? 0);
697
+ const totalRecords = Number.isFinite(rawTotal) && rawTotal > 0 ? rawTotal : 0;
698
+ const resultPayload = {
699
+ command: 'gallery-status',
700
+ available: totalRecords > 0,
701
+ totalRecords,
702
+ };
703
+ outputResult(args, resultPayload, [
704
+ `my gallery: ${resultPayload.available ? 'available' : 'unavailable'}`,
705
+ `totalRecords: ${totalRecords}`,
706
+ resultPayload.available
707
+ ? 'knowledge-img can be enabled.'
708
+ : 'upload at least one image in the Xique frontend before enabling knowledge-img.',
709
+ ]);
710
+ return resultPayload;
711
+ }
712
+
635
713
  async function handleWizard(args, state) {
636
714
  const wizardArgs = await collectWizardArgs(args, state);
637
715
  writeNotice(args, `wizard config: ${buildWizardSummary(wizardArgs)}`);
@@ -755,6 +833,197 @@ async function handleOutline(args, state) {
755
833
  return resultPayload;
756
834
  }
757
835
 
836
+ async function handleOutlineView(args, state) {
837
+ const client = createAuthorizedClient(args, state);
838
+ const cid = resolveCid(args, state);
839
+ const response = await fetchOutlineDetail(client, cid);
840
+ const outlineDetail = response?.data || {};
841
+ const editableDocument = buildEditableOutlineDocument(cid, outlineDetail);
842
+ const savedPath = stringOption(args, 'out')
843
+ ? writeOutlineEditFile(stringOption(args, 'out'), cid, editableDocument)
844
+ : null;
845
+
846
+ const resultPayload = {
847
+ command: 'outline-view',
848
+ cid,
849
+ outlineDetail,
850
+ editableDocument: savedPath ? editableDocument : undefined,
851
+ savedPath,
852
+ };
853
+ outputResult(args, resultPayload, [
854
+ ...formatOutlineLines(cid, outlineDetail),
855
+ savedPath ? `savedPath: ${savedPath}` : 'hint: pass --out <file-or-directory> to save an editable JSON file.',
856
+ ]);
857
+ return resultPayload;
858
+ }
859
+
860
+ async function handleOutlineUpdate(args, state) {
861
+ const client = createAuthorizedClient(args, state);
862
+ const cid = resolveCid(args, state);
863
+ const filePath = path.resolve(requiredOption(args, 'file'));
864
+ const editDocument = readOutlineEditFile(filePath);
865
+ validateOutlineEditCid(editDocument, cid, filePath);
866
+
867
+ const currentResponse = await fetchOutlineDetail(client, cid);
868
+ const currentOutlineDetail = currentResponse?.data || {};
869
+ const proposedRows = extractOutlineRows(editDocument);
870
+ const prepared = prepareOutlineUpdate(getOutlineRows(currentOutlineDetail), proposedRows);
871
+ const requestData = {
872
+ cid,
873
+ outLine: prepared.payload,
874
+ source: 2,
875
+ };
876
+ const baseResult = {
877
+ command: 'outline-update',
878
+ cid,
879
+ file: filePath,
880
+ dryRun: booleanOption(args, 'dryRun'),
881
+ applied: false,
882
+ hasChanges: prepared.hasChanges,
883
+ diff: prepared.diff,
884
+ requestPreview: requestData,
885
+ };
886
+ const diffLines = formatOutlineDiffLines(prepared.diff);
887
+
888
+ if (!prepared.hasChanges) {
889
+ const resultPayload = {
890
+ ...baseResult,
891
+ reason: 'no_changes',
892
+ };
893
+ outputResult(args, resultPayload, [
894
+ `outline unchanged: cid=${cid}`,
895
+ ...diffLines,
896
+ ]);
897
+ return resultPayload;
898
+ }
899
+
900
+ if (booleanOption(args, 'dryRun')) {
901
+ const resultPayload = {
902
+ ...baseResult,
903
+ reason: 'dry_run',
904
+ };
905
+ outputResult(args, resultPayload, [
906
+ `outline update preview: cid=${cid}`,
907
+ ...diffLines,
908
+ 'dry-run only: no server data was changed.',
909
+ prepared.diff.deleted.length > 0
910
+ ? 'delete protection: the real update also requires --allow-delete.'
911
+ : 'hint: rerun with --yes to apply this update.',
912
+ ]);
913
+ return resultPayload;
914
+ }
915
+
916
+ if (prepared.diff.deleted.length > 0 && !booleanOption(args, 'allowDelete')) {
917
+ throw new Error(`Outline update would delete ${prepared.diff.deleted.length} existing chapter(s). Review with --dry-run, then pass --allow-delete to confirm that deletion scope.`);
918
+ }
919
+
920
+ const confirmed = booleanOption(args, 'yes') || await confirmOutlineUpdate(args, cid, prepared.diff);
921
+ if (!confirmed) {
922
+ const resultPayload = {
923
+ ...baseResult,
924
+ reason: 'cancelled',
925
+ };
926
+ outputResult(args, resultPayload, [
927
+ `outline update cancelled: cid=${cid}`,
928
+ ...diffLines,
929
+ ]);
930
+ return resultPayload;
931
+ }
932
+
933
+ const response = await postJson(client, '/c/updateOutLine', requestData);
934
+ saveTaskSnapshot(state, cid, {
935
+ outlineUpdatedAt: new Date().toISOString(),
936
+ });
937
+ const resultPayload = {
938
+ ...baseResult,
939
+ applied: true,
940
+ response,
941
+ };
942
+ outputResult(args, resultPayload, [
943
+ `outline update accepted: cid=${cid}`,
944
+ ...diffLines,
945
+ 'note: changed chapters may trigger asynchronous directory regeneration on the server.',
946
+ `next: xq-cli outline view --cid ${cid}`,
947
+ ]);
948
+ return resultPayload;
949
+ }
950
+
951
+ async function handleDirectoryView(args, state) {
952
+ const client = createAuthorizedClient(args, state);
953
+ const cid = resolveCid(args, state);
954
+ const response = await getJson(
955
+ client,
956
+ `/task/directory/tree/${encodeURIComponent(cid)}`,
957
+ {},
958
+ { retries: DEFAULT_RETRY_COUNT },
959
+ );
960
+ const directoryDetail = response?.data || {};
961
+ const directoryDocument = buildDirectoryViewDocument(cid, directoryDetail);
962
+ const savedPath = stringOption(args, 'out')
963
+ ? writeDirectoryViewFile(stringOption(args, 'out'), cid, directoryDocument)
964
+ : null;
965
+
966
+ const resultPayload = {
967
+ command: 'directory-view',
968
+ cid,
969
+ directoryDetail,
970
+ directoryDocument: savedPath ? directoryDocument : undefined,
971
+ savedPath,
972
+ };
973
+ outputResult(args, resultPayload, [
974
+ ...formatDirectoryLines(cid, directoryDetail),
975
+ savedPath ? `savedPath: ${savedPath}` : 'hint: pass --out <file-or-directory> to save the complete directory JSON.',
976
+ ]);
977
+ return resultPayload;
978
+ }
979
+
980
+ async function confirmOutlineUpdate(args, cid, diff) {
981
+ if (!supportsInteractivePrompt()) {
982
+ throw new Error('Outline update changes server data. Review it with --dry-run, then rerun with --yes.');
983
+ }
984
+ return runPromptSession(args, async context => {
985
+ context.promptStream.write(`\n${formatOutlineDiffLines(diff).join('\n')}\n`);
986
+ return promptYesNo(context, `Apply this outline update to cid=${cid}`, false);
987
+ });
988
+ }
989
+
990
+ function readOutlineEditFile(filePath) {
991
+ ensureFileExists(filePath);
992
+ try {
993
+ const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
994
+ return JSON.parse(raw);
995
+ } catch (error) {
996
+ throw new Error(`Failed to parse outline JSON: ${filePath}`);
997
+ }
998
+ }
999
+
1000
+ function validateOutlineEditCid(document, cid, filePath) {
1001
+ const documentCid = document && !Array.isArray(document) ? String(document.cid || '').trim() : '';
1002
+ if (documentCid && documentCid !== cid) {
1003
+ throw new Error(`Outline file cid (${documentCid}) does not match --cid (${cid}): ${filePath}`);
1004
+ }
1005
+ }
1006
+
1007
+ function writeOutlineEditFile(outputOption, cid, document) {
1008
+ const resolvedOutput = path.resolve(outputOption);
1009
+ const outputIsDirectory = !path.extname(resolvedOutput);
1010
+ const defaultName = `outline-${normalizeDownloadFileName(cid) || 'task'}.json`;
1011
+ const finalPath = outputIsDirectory ? path.join(resolvedOutput, defaultName) : resolvedOutput;
1012
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true });
1013
+ fs.writeFileSync(finalPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
1014
+ return finalPath;
1015
+ }
1016
+
1017
+ function writeDirectoryViewFile(outputOption, cid, document) {
1018
+ const resolvedOutput = path.resolve(outputOption);
1019
+ const outputIsDirectory = !path.extname(resolvedOutput);
1020
+ const defaultName = `directory-${normalizeDownloadFileName(cid) || 'task'}.json`;
1021
+ const finalPath = outputIsDirectory ? path.join(resolvedOutput, defaultName) : resolvedOutput;
1022
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true });
1023
+ fs.writeFileSync(finalPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
1024
+ return finalPath;
1025
+ }
1026
+
758
1027
  async function handleWrite(args, state) {
759
1028
  const client = createAuthorizedClient(args, state);
760
1029
  const cid = resolveCid(args, state);
@@ -0,0 +1,89 @@
1
+ export function extractDirectoryNodes(document = {}) {
2
+ const candidates = [
3
+ document,
4
+ document?.directoryNodes,
5
+ document?.data?.directoryNodes,
6
+ document?.directoryDetail?.directoryNodes,
7
+ document?.contentDetail?.directoryNodes,
8
+ ];
9
+ for (const candidate of candidates) {
10
+ if (Array.isArray(candidate)) {
11
+ return candidate;
12
+ }
13
+ }
14
+ return [];
15
+ }
16
+
17
+ export function buildDirectoryViewDocument(cid, directoryDetail = {}) {
18
+ const detail = directoryDetail && typeof directoryDetail === 'object' && !Array.isArray(directoryDetail)
19
+ ? directoryDetail
20
+ : {};
21
+ return {
22
+ format: 'xq-cli-directory@1',
23
+ ...detail,
24
+ cid,
25
+ directoryNodes: extractDirectoryNodes(detail),
26
+ };
27
+ }
28
+
29
+ export function getDirectoryStats(document = {}) {
30
+ const roots = extractDirectoryNodes(document);
31
+ let nodes = 0;
32
+ let maxDepth = 0;
33
+
34
+ function visit(rows, depth) {
35
+ for (const row of rows) {
36
+ nodes += 1;
37
+ maxDepth = Math.max(maxDepth, depth);
38
+ const children = Array.isArray(row?.children) ? row.children : [];
39
+ visit(children, depth + 1);
40
+ }
41
+ }
42
+
43
+ visit(roots, 1);
44
+ return {
45
+ roots: roots.length,
46
+ nodes,
47
+ maxDepth,
48
+ };
49
+ }
50
+
51
+ export function formatDirectoryLines(cid, directoryDetail = {}) {
52
+ const roots = extractDirectoryNodes(directoryDetail);
53
+ const stats = getDirectoryStats(roots);
54
+ const lines = [
55
+ `directory: cid=${cid}`,
56
+ `title: ${normalizeText(directoryDetail?.title) || '-'}`,
57
+ `task_status: ${directoryDetail?.task_status ?? '-'}`,
58
+ `roots: ${stats.roots}`,
59
+ `nodes: ${stats.nodes}`,
60
+ `max_depth: ${stats.maxDepth}`,
61
+ ];
62
+
63
+ if (roots.length === 0) {
64
+ lines.push('directory tree: empty');
65
+ return lines;
66
+ }
67
+
68
+ lines.push('directory tree:');
69
+ appendDirectoryRows(lines, roots);
70
+ return lines;
71
+ }
72
+
73
+ function appendDirectoryRows(lines, rows, prefix = [], depth = 0) {
74
+ rows.forEach((row, index) => {
75
+ const position = [...prefix, index + 1];
76
+ const text = normalizeText(row?.text || row?.title || row?.name) || '(unnamed directory)';
77
+ const id = normalizeText(row?.id);
78
+ lines.push(`${' '.repeat(depth)}${position.join('.')}. ${text}${id ? ` [id=${id}]` : ''}`);
79
+ const children = Array.isArray(row?.children) ? row.children : [];
80
+ appendDirectoryRows(lines, children, position, depth + 1);
81
+ });
82
+ }
83
+
84
+ function normalizeText(value) {
85
+ if (value === undefined || value === null) {
86
+ return '';
87
+ }
88
+ return String(value).trim();
89
+ }
@@ -0,0 +1,298 @@
1
+ const CHAPTER_PREFIX_PATTERNS = [
2
+ /^\s*第[零〇一二三四五六七八九十百千万两\d]+章\s*/u,
3
+ /^\s*\d+\s*[、..]\s*/u,
4
+ ];
5
+
6
+ export function extractOutlineRows(document) {
7
+ const candidates = [
8
+ document,
9
+ document?.outLine,
10
+ document?.data?.outLine,
11
+ document?.outlineDetail?.outLine,
12
+ document?.waitResult?.outlineDetail?.outLine,
13
+ ];
14
+ const rows = candidates.find(Array.isArray);
15
+ if (!rows) {
16
+ throw new Error('Outline JSON must be an array or contain an outLine array.');
17
+ }
18
+ return rows;
19
+ }
20
+
21
+ export function buildEditableOutlineDocument(cid, outlineDetail = {}) {
22
+ return {
23
+ format: 'xq-cli-outline@1',
24
+ cid,
25
+ outlineStatus: Boolean(outlineDetail.outline_status),
26
+ outLine: extractOutlineRows(outlineDetail).map(row => compactObject({
27
+ id: normalizeOptionalString(row?.id),
28
+ text: normalizeText(row?.text),
29
+ theme: normalizeTheme(row?.theme),
30
+ important: normalizeImportant(row?.important, 2),
31
+ })),
32
+ };
33
+ }
34
+
35
+ export function prepareOutlineUpdate(currentRows, proposedRows) {
36
+ if (!Array.isArray(currentRows)) {
37
+ throw new Error('Current outline data is invalid: outLine must be an array.');
38
+ }
39
+ if (!Array.isArray(proposedRows) || proposedRows.length === 0) {
40
+ throw new Error('Updated outline must contain at least one chapter.');
41
+ }
42
+
43
+ const current = currentRows.map((row, index) => normalizeCurrentRow(row, index));
44
+ const currentById = new Map(current.filter(row => row.id).map(row => [row.id, row]));
45
+ const seenIds = new Set();
46
+
47
+ const proposed = proposedRows.map((row, index) => {
48
+ if (!row || typeof row !== 'object' || Array.isArray(row)) {
49
+ throw new Error(`Outline chapter ${index + 1} must be a JSON object.`);
50
+ }
51
+
52
+ const id = normalizeOptionalString(row.id);
53
+ if (id) {
54
+ if (seenIds.has(id)) {
55
+ throw new Error(`Duplicate outline chapter id: ${id}`);
56
+ }
57
+ if (!currentById.has(id)) {
58
+ throw new Error(`Outline chapter id does not belong to the current task: ${id}`);
59
+ }
60
+ seenIds.add(id);
61
+ }
62
+
63
+ const text = normalizeText(row.text);
64
+ if (!stripChapterPrefix(text)) {
65
+ throw new Error(`Outline chapter ${index + 1} has an empty text field.`);
66
+ }
67
+
68
+ const existing = id ? currentById.get(id) : null;
69
+ const important = normalizeImportant(row.important, existing?.important ?? 2, index);
70
+ const theme = normalizeTheme(row.theme);
71
+ const sequelFlag = normalizeSequelFlag(row.sequelFlag, index);
72
+ return {
73
+ id,
74
+ text,
75
+ theme,
76
+ important,
77
+ sequelFlag,
78
+ index,
79
+ existing,
80
+ };
81
+ });
82
+
83
+ const added = [];
84
+ const updated = [];
85
+ const importanceChanged = [];
86
+ const moved = [];
87
+ const sequelRequested = [];
88
+ const payload = proposed.map(row => {
89
+ const existing = row.existing;
90
+ const changedFields = existing
91
+ ? collectChangedFields(existing, row)
92
+ : [];
93
+ const contentChanged = changedFields.includes('text') || changedFields.includes('theme');
94
+
95
+ if (!existing) {
96
+ added.push(describeAddedRow(row));
97
+ } else {
98
+ if (contentChanged) {
99
+ updated.push(describeUpdatedRow(existing, row, changedFields));
100
+ }
101
+ if (existing.important !== row.important) {
102
+ importanceChanged.push({
103
+ id: row.id,
104
+ text: row.text,
105
+ before: existing.important,
106
+ after: row.important,
107
+ });
108
+ }
109
+ if (existing.index !== row.index) {
110
+ moved.push({
111
+ id: row.id,
112
+ text: row.text,
113
+ from: existing.index + 1,
114
+ to: row.index + 1,
115
+ });
116
+ }
117
+ }
118
+ if (row.sequelFlag === 1) {
119
+ sequelRequested.push({ id: row.id || null, text: row.text, index: row.index + 1 });
120
+ }
121
+
122
+ return compactObject({
123
+ id: row.id,
124
+ text: row.text,
125
+ theme: row.theme,
126
+ updateFlag: contentChanged ? 1 : 0,
127
+ important: row.important,
128
+ sequelFlag: row.sequelFlag === 1 ? 1 : undefined,
129
+ });
130
+ });
131
+
132
+ const proposedIds = new Set(proposed.map(row => row.id).filter(Boolean));
133
+ const deleted = current
134
+ .filter(row => row.id && !proposedIds.has(row.id))
135
+ .map(row => ({
136
+ id: row.id,
137
+ text: row.text,
138
+ index: row.index + 1,
139
+ }));
140
+
141
+ const diff = {
142
+ added,
143
+ updated,
144
+ deleted,
145
+ moved,
146
+ importanceChanged,
147
+ sequelRequested,
148
+ };
149
+ return {
150
+ payload,
151
+ diff,
152
+ hasChanges: Object.values(diff).some(items => items.length > 0),
153
+ };
154
+ }
155
+
156
+ export function formatOutlineLines(cid, outlineDetail = {}) {
157
+ const rows = Array.isArray(outlineDetail?.outLine) ? outlineDetail.outLine : [];
158
+ const lines = [
159
+ `outline: cid=${cid}`,
160
+ `outline_status: ${outlineDetail?.outline_status ? 'ready' : 'not-ready'}`,
161
+ `chapters: ${rows.length}`,
162
+ ];
163
+ rows.forEach((row, index) => {
164
+ const id = normalizeOptionalString(row?.id);
165
+ const important = normalizeImportant(row?.important, 2) === 1 ? '重点' : '普通';
166
+ lines.push(`${index + 1}. ${normalizeText(row?.text) || '(未命名章节)'} [${important}${id ? `, id=${id}` : ''}]`);
167
+ const themeLines = normalizeTheme(row?.theme)
168
+ .split(/\r?\n/)
169
+ .map(line => line.trim())
170
+ .filter(Boolean);
171
+ if (themeLines.length === 0) {
172
+ lines.push(' 主题: -');
173
+ } else {
174
+ lines.push(' 主题:');
175
+ themeLines.forEach(line => lines.push(` - ${line}`));
176
+ }
177
+ });
178
+ return lines;
179
+ }
180
+
181
+ export function formatOutlineDiffLines(diff = {}) {
182
+ const added = diff.added || [];
183
+ const updated = diff.updated || [];
184
+ const deleted = diff.deleted || [];
185
+ const moved = diff.moved || [];
186
+ const importanceChanged = diff.importanceChanged || [];
187
+ const sequelRequested = diff.sequelRequested || [];
188
+ const lines = [
189
+ `changes: added=${added.length}, updated=${updated.length}, deleted=${deleted.length}, moved=${moved.length}, importance=${importanceChanged.length}, sequel=${sequelRequested.length}`,
190
+ ];
191
+ added.forEach(item => lines.push(` + ${item.index}. ${item.text}`));
192
+ updated.forEach(item => lines.push(` ~ ${item.index}. ${item.text} (${item.fields.join(', ')})`));
193
+ deleted.forEach(item => lines.push(` - ${item.index}. ${item.text} [id=${item.id}]`));
194
+ moved.forEach(item => lines.push(` ⇅ ${item.text}: ${item.from} -> ${item.to}`));
195
+ importanceChanged.forEach(item => lines.push(` ! ${item.text}: important ${item.before} -> ${item.after}`));
196
+ sequelRequested.forEach(item => lines.push(` > ${item.index}. ${item.text}: request sequel`));
197
+ return lines;
198
+ }
199
+
200
+ function normalizeCurrentRow(row, index) {
201
+ return {
202
+ id: normalizeOptionalString(row?.id),
203
+ text: normalizeText(row?.text),
204
+ theme: normalizeTheme(row?.theme),
205
+ important: normalizeImportant(row?.important, 2),
206
+ index,
207
+ };
208
+ }
209
+
210
+ function collectChangedFields(existing, proposed) {
211
+ const fields = [];
212
+ if (stripChapterPrefix(existing.text) !== stripChapterPrefix(proposed.text)) {
213
+ fields.push('text');
214
+ }
215
+ if (existing.theme.trim() !== proposed.theme.trim()) {
216
+ fields.push('theme');
217
+ }
218
+ return fields;
219
+ }
220
+
221
+ function describeAddedRow(row) {
222
+ return {
223
+ id: null,
224
+ text: row.text,
225
+ index: row.index + 1,
226
+ };
227
+ }
228
+
229
+ function describeUpdatedRow(existing, row, fields) {
230
+ return {
231
+ id: row.id,
232
+ text: row.text,
233
+ index: row.index + 1,
234
+ fields,
235
+ before: {
236
+ text: existing.text,
237
+ theme: existing.theme,
238
+ },
239
+ after: {
240
+ text: row.text,
241
+ theme: row.theme,
242
+ },
243
+ };
244
+ }
245
+
246
+ function normalizeText(value) {
247
+ return value === undefined || value === null ? '' : String(value).trim();
248
+ }
249
+
250
+ function normalizeTheme(value) {
251
+ if (Array.isArray(value)) {
252
+ return value.map(item => String(item).trim()).filter(Boolean).join('\n');
253
+ }
254
+ return value === undefined || value === null ? '' : String(value).trim();
255
+ }
256
+
257
+ function normalizeOptionalString(value) {
258
+ if (value === undefined || value === null) {
259
+ return undefined;
260
+ }
261
+ const normalized = String(value).trim();
262
+ return normalized || undefined;
263
+ }
264
+
265
+ function normalizeImportant(value, fallback, index = null) {
266
+ if (value === undefined || value === null || value === '') {
267
+ return fallback;
268
+ }
269
+ const parsed = Number(value);
270
+ if (parsed !== 1 && parsed !== 2) {
271
+ const location = index === null ? '' : ` for chapter ${index + 1}`;
272
+ throw new Error(`Outline important${location} must be 1 (重点) or 2 (普通).`);
273
+ }
274
+ return parsed;
275
+ }
276
+
277
+ function normalizeSequelFlag(value, index) {
278
+ if (value === undefined || value === null || value === '') {
279
+ return undefined;
280
+ }
281
+ const parsed = Number(value);
282
+ if (parsed !== 0 && parsed !== 1) {
283
+ throw new Error(`Outline sequelFlag for chapter ${index + 1} must be 0 or 1.`);
284
+ }
285
+ return parsed;
286
+ }
287
+
288
+ function stripChapterPrefix(value) {
289
+ let normalized = normalizeText(value).replace(/<[^>]+>/g, '').trim();
290
+ for (const pattern of CHAPTER_PREFIX_PATTERNS) {
291
+ normalized = normalized.replace(pattern, '').trim();
292
+ }
293
+ return normalized;
294
+ }
295
+
296
+ function compactObject(value) {
297
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
298
+ }