@xqyz/xq-cli 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -40,26 +40,31 @@ npm pack
40
40
  That command produces a `.tgz` tarball which can be shared and installed with:
41
41
 
42
42
  ```bash
43
- npm install -g .\xqyz-xq-cli-0.1.1.tgz
43
+ npm install -g .\xqyz-xq-cli-0.1.3.tgz
44
44
  ```
45
45
 
46
46
  ## Usage
47
47
 
48
48
  ```bash
49
- xq-cli login --base-url https://ai.bidfile.qianlima.com/api
50
- xq-cli login --base-url https://ai.bidfile.qianlima.com/api --name your-account --password your-password
49
+ xq-cli login
50
+ xq-cli login --name your-account --password your-password
51
+ xq-cli login --base-url https://xqai.atest.qianlima.com/api --browser
51
52
  xq-cli wizard
52
53
  xq-cli wizard --file D:\bids\tender.docx
53
54
  xq-cli init --file D:\bids\tender.docx --wait
54
55
  xq-cli outline --cid <cid> --interactive --wait
55
56
  xq-cli outline --cid <cid> --wait
57
+ xq-cli outline view --cid <cid>
58
+ xq-cli outline view --cid <cid> --out D:\output\outline.json
59
+ xq-cli outline update --cid <cid> --file D:\output\outline.json --dry-run --json
60
+ xq-cli outline update --cid <cid> --file D:\output\outline.json --yes
56
61
  xq-cli write --cid <cid> --wait
57
62
  xq-cli status --cid <cid> --content --json
58
63
  xq-cli export --cid <cid> --interactive --out D:\output
59
64
  xq-cli export --cid <cid> --out D:\output
60
65
  ```
61
66
 
62
- Fresh installs do not ship with a saved API address. Users must run `login` once to save the target `--base-url`.
67
+ Fresh installs default to the production API address `https://ai.bidfile.qianlima.com/api`. Use `--base-url` only when you need to target another environment such as `https://xqai.atest.qianlima.com/api`.
63
68
 
64
69
  `login` now defaults to browser authorization. If you explicitly pass `--name` and `--password`, the CLI falls back to password login.
65
70
 
@@ -71,6 +76,43 @@ When `outline --wait` or `write --wait` hits a required response-basis step, the
71
76
 
72
77
  `order/charge -> c/viewStep(101) -> task/autoGenerateContent`
73
78
 
79
+ ## View and update an existing outline
80
+
81
+ View the complete current outline without triggering outline generation:
82
+
83
+ ```bash
84
+ xq-cli outline view --cid <cid>
85
+ xq-cli outline view --cid <cid> --json
86
+ ```
87
+
88
+ Export an editable JSON copy:
89
+
90
+ ```bash
91
+ xq-cli outline view --cid <cid> --out D:\output\outline.json
92
+ ```
93
+
94
+ 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).
95
+
96
+ Always preview the server-side diff first:
97
+
98
+ ```bash
99
+ xq-cli outline update --cid <cid> --file D:\output\outline.json --dry-run
100
+ ```
101
+
102
+ Apply after reviewing the preview:
103
+
104
+ ```bash
105
+ xq-cli outline update --cid <cid> --file D:\output\outline.json --yes
106
+ ```
107
+
108
+ Deleting existing chapters has an additional protection flag:
109
+
110
+ ```bash
111
+ xq-cli outline update --cid <cid> --file D:\output\outline.json --yes --allow-delete
112
+ ```
113
+
114
+ 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.
115
+
74
116
  ## Interactive menus
75
117
 
76
118
  Use the full wizard if you want a CLI flow close to the frontend configuration panel:
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@xqyz/xq-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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
11
  "xq-cli": "./bin/xq-cli.js"
package/src/cli.mjs CHANGED
@@ -12,9 +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';
15
22
 
16
23
  const APP_DIR = path.join(os.homedir(), '.xq-opencli');
17
24
  const CONFIG_FILE = path.join(APP_DIR, 'config.json');
25
+ const DEFAULT_BASE_URL = 'https://ai.bidfile.qianlima.com/api';
18
26
  const DEFAULT_PARSE_INTERVAL_MS = 10_000;
19
27
  const DEFAULT_TASK_INTERVAL_MS = 10_000;
20
28
  const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
@@ -327,7 +335,19 @@ async function main() {
327
335
  await handleParseStatus(parsed, state);
328
336
  return;
329
337
  case 'outline':
330
- await handleOutline(parsed, state);
338
+ if (isOutlineAction(parsed, ['view', 'show'])) {
339
+ await handleOutlineView(parsed, state);
340
+ } else if (isOutlineAction(parsed, ['update', 'edit'])) {
341
+ await handleOutlineUpdate(parsed, state);
342
+ } else {
343
+ await handleOutline(parsed, state);
344
+ }
345
+ return;
346
+ case 'outline-view':
347
+ await handleOutlineView(parsed, state);
348
+ return;
349
+ case 'outline-update':
350
+ await handleOutlineUpdate(parsed, state);
331
351
  return;
332
352
  case 'write':
333
353
  await handleWrite(parsed, state);
@@ -358,6 +378,8 @@ Commands
358
378
  init Upload files and initialize a task
359
379
  parse-status Query or wait for parse completion
360
380
  outline Pre-set task params and trigger outline generation
381
+ outline view View the current outline without triggering generation
382
+ outline update Update the current outline from an edited JSON file
361
383
  write Charge if needed, enter content phase, and trigger content generation
362
384
  status Query task status
363
385
  choices Print selectable outline/export config values
@@ -365,9 +387,9 @@ Commands
365
387
  export Download the generated docx
366
388
 
367
389
  Examples
368
- xq-cli login --base-url https://ai.bidfile.qianlima.com/api
369
- xq-cli login --base-url https://ai.bidfile.qianlima.com/api --name your-account --password your-password
370
- xq-cli login --base-url https://ai.bidfile.qianlima.com/api --browser
390
+ xq-cli login
391
+ xq-cli login --name your-account --password your-password
392
+ xq-cli login --base-url https://xqai.atest.qianlima.com/api --browser
371
393
  xq-cli init --file D:\\bids\\tender.docx --wait
372
394
  xq-cli parse-status --cid <cid> --uuid <uuid> --wait
373
395
  xq-cli choices
@@ -381,6 +403,10 @@ Examples
381
403
  xq-cli outline --cid <cid> --wait --ignore-missing-bill
382
404
  xq-cli outline --cid <cid> --wait --reference-type 0
383
405
  xq-cli outline --cid <cid> --page-scope 3 --base expert --theme-style 4 --table-quantity rich --plan-mode quick --wait
406
+ xq-cli outline view --cid <cid>
407
+ xq-cli outline view --cid <cid> --out D:\\output\\outline.json
408
+ xq-cli outline update --cid <cid> --file D:\\output\\outline.json --dry-run --json
409
+ xq-cli outline update --cid <cid> --file D:\\output\\outline.json --yes
384
410
  xq-cli write --cid <cid> --type 1 --wait
385
411
  xq-cli status --cid <cid> --content
386
412
  xq-cli export --cid <cid> --interactive --out D:\\output\\
@@ -389,12 +415,15 @@ Examples
389
415
 
390
416
  Common options
391
417
  --json Print machine-readable JSON output
392
- --base-url <url> Override the saved base URL
418
+ --base-url <url> Override the default or saved base URL
393
419
  --timeout-sec <n> Poll timeout in seconds, default 600
394
420
  --interval-sec <n> Poll interval in seconds, default 10
395
421
  --reference-type <0|1|2> Outline reference choice: 0=merge, 1=format only, 2=score only
396
422
  --browser Force browser authorization login; browser is the default when credentials are not provided
397
423
  --interactive Open prompt menus for the current command
424
+ --dry-run Preview an outline update without saving it
425
+ --yes Confirm an outline update without an interactive prompt
426
+ --allow-delete Allow an outline update that removes existing chapters
398
427
  --ignore-short-requirement Follow the frontend ignore/continue path for requirement_judgement=0
399
428
  --ignore-missing-bill Follow the frontend ignore/continue path for billOfQuantities_judgement=0
400
429
 
@@ -442,6 +471,11 @@ Export style
442
471
  `.trim());
443
472
  }
444
473
 
474
+ function isOutlineAction(args, actions) {
475
+ const action = String(args?._?.[1] || '').trim().toLowerCase();
476
+ return actions.includes(action);
477
+ }
478
+
445
479
  async function handleLogin(args, state) {
446
480
  const baseUrl = normalizeBaseUrl(stringOption(args, 'baseUrl', state.baseUrl));
447
481
  if (!baseUrl) {
@@ -754,6 +788,158 @@ async function handleOutline(args, state) {
754
788
  return resultPayload;
755
789
  }
756
790
 
791
+ async function handleOutlineView(args, state) {
792
+ const client = createAuthorizedClient(args, state);
793
+ const cid = resolveCid(args, state);
794
+ const response = await fetchOutlineDetail(client, cid);
795
+ const outlineDetail = response?.data || {};
796
+ const editableDocument = buildEditableOutlineDocument(cid, outlineDetail);
797
+ const savedPath = stringOption(args, 'out')
798
+ ? writeOutlineEditFile(stringOption(args, 'out'), cid, editableDocument)
799
+ : null;
800
+
801
+ const resultPayload = {
802
+ command: 'outline-view',
803
+ cid,
804
+ outlineDetail,
805
+ editableDocument: savedPath ? editableDocument : undefined,
806
+ savedPath,
807
+ };
808
+ outputResult(args, resultPayload, [
809
+ ...formatOutlineLines(cid, outlineDetail),
810
+ savedPath ? `savedPath: ${savedPath}` : 'hint: pass --out <file-or-directory> to save an editable JSON file.',
811
+ ]);
812
+ return resultPayload;
813
+ }
814
+
815
+ async function handleOutlineUpdate(args, state) {
816
+ const client = createAuthorizedClient(args, state);
817
+ const cid = resolveCid(args, state);
818
+ const filePath = path.resolve(requiredOption(args, 'file'));
819
+ const editDocument = readOutlineEditFile(filePath);
820
+ validateOutlineEditCid(editDocument, cid, filePath);
821
+
822
+ const currentResponse = await fetchOutlineDetail(client, cid);
823
+ const currentOutlineDetail = currentResponse?.data || {};
824
+ const proposedRows = extractOutlineRows(editDocument);
825
+ const prepared = prepareOutlineUpdate(getOutlineRows(currentOutlineDetail), proposedRows);
826
+ const requestData = {
827
+ cid,
828
+ outLine: prepared.payload,
829
+ source: 2,
830
+ };
831
+ const baseResult = {
832
+ command: 'outline-update',
833
+ cid,
834
+ file: filePath,
835
+ dryRun: booleanOption(args, 'dryRun'),
836
+ applied: false,
837
+ hasChanges: prepared.hasChanges,
838
+ diff: prepared.diff,
839
+ requestPreview: requestData,
840
+ };
841
+ const diffLines = formatOutlineDiffLines(prepared.diff);
842
+
843
+ if (!prepared.hasChanges) {
844
+ const resultPayload = {
845
+ ...baseResult,
846
+ reason: 'no_changes',
847
+ };
848
+ outputResult(args, resultPayload, [
849
+ `outline unchanged: cid=${cid}`,
850
+ ...diffLines,
851
+ ]);
852
+ return resultPayload;
853
+ }
854
+
855
+ if (booleanOption(args, 'dryRun')) {
856
+ const resultPayload = {
857
+ ...baseResult,
858
+ reason: 'dry_run',
859
+ };
860
+ outputResult(args, resultPayload, [
861
+ `outline update preview: cid=${cid}`,
862
+ ...diffLines,
863
+ 'dry-run only: no server data was changed.',
864
+ prepared.diff.deleted.length > 0
865
+ ? 'delete protection: the real update also requires --allow-delete.'
866
+ : 'hint: rerun with --yes to apply this update.',
867
+ ]);
868
+ return resultPayload;
869
+ }
870
+
871
+ if (prepared.diff.deleted.length > 0 && !booleanOption(args, 'allowDelete')) {
872
+ 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.`);
873
+ }
874
+
875
+ const confirmed = booleanOption(args, 'yes') || await confirmOutlineUpdate(args, cid, prepared.diff);
876
+ if (!confirmed) {
877
+ const resultPayload = {
878
+ ...baseResult,
879
+ reason: 'cancelled',
880
+ };
881
+ outputResult(args, resultPayload, [
882
+ `outline update cancelled: cid=${cid}`,
883
+ ...diffLines,
884
+ ]);
885
+ return resultPayload;
886
+ }
887
+
888
+ const response = await postJson(client, '/c/updateOutLine', requestData);
889
+ saveTaskSnapshot(state, cid, {
890
+ outlineUpdatedAt: new Date().toISOString(),
891
+ });
892
+ const resultPayload = {
893
+ ...baseResult,
894
+ applied: true,
895
+ response,
896
+ };
897
+ outputResult(args, resultPayload, [
898
+ `outline update accepted: cid=${cid}`,
899
+ ...diffLines,
900
+ 'note: changed chapters may trigger asynchronous directory regeneration on the server.',
901
+ `next: xq-cli outline view --cid ${cid}`,
902
+ ]);
903
+ return resultPayload;
904
+ }
905
+
906
+ async function confirmOutlineUpdate(args, cid, diff) {
907
+ if (!supportsInteractivePrompt()) {
908
+ throw new Error('Outline update changes server data. Review it with --dry-run, then rerun with --yes.');
909
+ }
910
+ return runPromptSession(args, async context => {
911
+ context.promptStream.write(`\n${formatOutlineDiffLines(diff).join('\n')}\n`);
912
+ return promptYesNo(context, `Apply this outline update to cid=${cid}`, false);
913
+ });
914
+ }
915
+
916
+ function readOutlineEditFile(filePath) {
917
+ ensureFileExists(filePath);
918
+ try {
919
+ const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
920
+ return JSON.parse(raw);
921
+ } catch (error) {
922
+ throw new Error(`Failed to parse outline JSON: ${filePath}`);
923
+ }
924
+ }
925
+
926
+ function validateOutlineEditCid(document, cid, filePath) {
927
+ const documentCid = document && !Array.isArray(document) ? String(document.cid || '').trim() : '';
928
+ if (documentCid && documentCid !== cid) {
929
+ throw new Error(`Outline file cid (${documentCid}) does not match --cid (${cid}): ${filePath}`);
930
+ }
931
+ }
932
+
933
+ function writeOutlineEditFile(outputOption, cid, document) {
934
+ const resolvedOutput = path.resolve(outputOption);
935
+ const outputIsDirectory = !path.extname(resolvedOutput);
936
+ const defaultName = `outline-${normalizeDownloadFileName(cid) || 'task'}.json`;
937
+ const finalPath = outputIsDirectory ? path.join(resolvedOutput, defaultName) : resolvedOutput;
938
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true });
939
+ fs.writeFileSync(finalPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
940
+ return finalPath;
941
+ }
942
+
757
943
  async function handleWrite(args, state) {
758
944
  const client = createAuthorizedClient(args, state);
759
945
  const cid = resolveCid(args, state);
@@ -3193,26 +3379,34 @@ function normalizeBaseUrl(value) {
3193
3379
  return String(value).replace(/\/+$/, '');
3194
3380
  }
3195
3381
 
3382
+ function createDefaultState() {
3383
+ return {
3384
+ baseUrl: DEFAULT_BASE_URL,
3385
+ token: '',
3386
+ user: {},
3387
+ lastCid: '',
3388
+ tasks: {},
3389
+ };
3390
+ }
3391
+
3196
3392
  function readState() {
3393
+ const defaultState = createDefaultState();
3197
3394
  if (!fs.existsSync(CONFIG_FILE)) {
3198
- return {
3199
- baseUrl: '',
3200
- token: '',
3201
- user: {},
3202
- lastCid: '',
3203
- tasks: {},
3204
- };
3395
+ return defaultState;
3205
3396
  }
3206
3397
  try {
3207
- return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
3208
- } catch (error) {
3398
+ const parsed = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
3209
3399
  return {
3210
- baseUrl: '',
3211
- token: '',
3212
- user: {},
3213
- lastCid: '',
3214
- tasks: {},
3400
+ ...defaultState,
3401
+ ...parsed,
3402
+ baseUrl: normalizeBaseUrl(parsed?.baseUrl || defaultState.baseUrl),
3403
+ token: parsed?.token || '',
3404
+ user: parsed?.user && typeof parsed.user === 'object' ? parsed.user : {},
3405
+ lastCid: parsed?.lastCid || '',
3406
+ tasks: parsed?.tasks && typeof parsed.tasks === 'object' ? parsed.tasks : {},
3215
3407
  };
3408
+ } catch (error) {
3409
+ return defaultState;
3216
3410
  }
3217
3411
  }
3218
3412
 
@@ -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
+ }