@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 +46 -4
- package/package.json +3 -2
- package/src/cli.mjs +213 -19
- package/src/outline-edit.mjs +298 -0
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.
|
|
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
|
|
50
|
-
xq-cli login --
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
369
|
-
xq-cli login --
|
|
370
|
-
xq-cli login --base-url https://
|
|
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
|
-
|
|
3208
|
-
} catch (error) {
|
|
3398
|
+
const parsed = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
3209
3399
|
return {
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
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
|
+
}
|