@tiwater/office-mcp 0.8.0 → 0.10.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/office/README.md +45 -5
- package/office/index.mjs +251 -85
- package/package.json +1 -1
package/office/README.md
CHANGED
|
@@ -31,8 +31,48 @@ The official MCP SDK derives the schemas advertised to clients and validates
|
|
|
31
31
|
tool arguments and structured results before they cross the protocol boundary.
|
|
32
32
|
Large observations and exports are written to a caller-selected new JSON
|
|
33
33
|
artifact. MCP returns only the artifact path, hash, and byte count.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
|
|
35
|
+
## Template migration
|
|
36
|
+
|
|
37
|
+
Template migration separates business choice from document mechanics:
|
|
38
|
+
|
|
39
|
+
1. `docx_list_migration_choices` records the complete current source and target
|
|
40
|
+
catalog in an opaque run-local artifact.
|
|
41
|
+
2. `docx_query_migration_choices` pages source items and returns bounded,
|
|
42
|
+
document-compatible alternatives for one source item.
|
|
43
|
+
3. `docx_migrate_template` accepts one complete batch and derives the plan,
|
|
44
|
+
edits, and readback receipt.
|
|
45
|
+
4. `docx_verify_migration` independently verifies the output from the same
|
|
46
|
+
source, baseline, and batch.
|
|
47
|
+
|
|
48
|
+
The scenario supplies the business meaning. The query tool exposes three
|
|
49
|
+
orthogonal target actions:
|
|
50
|
+
|
|
51
|
+
- `place-content` moves current content into a target content position.
|
|
52
|
+
- `keep-template-label` keeps the target label and structure while migrating a
|
|
53
|
+
uniquely identified current field value.
|
|
54
|
+
- `select-template-option` marks a target option represented by the current
|
|
55
|
+
source fact.
|
|
56
|
+
|
|
57
|
+
Choose the action first, then query targets with that action filter. Returned
|
|
58
|
+
`alternativeRef` values bind the action and target together. Source exclusion
|
|
59
|
+
and genuine local review are target-free terminal choices. The caller never
|
|
60
|
+
supplies document text, selectors, coordinates, plans, or edit operations.
|
|
61
|
+
|
|
62
|
+
Template-migration choice artifacts are opaque evidence. List the choices once,
|
|
63
|
+
then query the same current source and baseline to page sources, request targets
|
|
64
|
+
for a source, or inspect cleanup targets. Target queries return complete
|
|
65
|
+
provider-compatible action-and-target alternatives under short catalog-bound
|
|
66
|
+
references. Submit selected alternatives and target-free terminal choices as
|
|
67
|
+
one batch, then verify the output independently from the same inputs and batch.
|
|
68
|
+
The tool does not choose the business mapping.
|
|
69
|
+
|
|
70
|
+
## Version 0.10 migration
|
|
71
|
+
|
|
72
|
+
Version 0.10 replaces the 0.9 template-migration identity form. Targeted
|
|
73
|
+
choices now use one `alternativeRef` returned by
|
|
74
|
+
`docx_query_migration_choices`; terminal choices use `sourceRef` plus
|
|
75
|
+
`exclude-source` or `review-source`. The server rejects the old combination of
|
|
76
|
+
raw source id, action, and raw target id so an action cannot be paired with a
|
|
77
|
+
target from a different alternative. Other Office tools keep their existing
|
|
78
|
+
inputs and outputs.
|
package/office/index.mjs
CHANGED
|
@@ -36,41 +36,28 @@ const convertCandidates = [
|
|
|
36
36
|
];
|
|
37
37
|
|
|
38
38
|
const pathInput = z.string().trim().min(1);
|
|
39
|
-
const
|
|
39
|
+
const migrationQueryTargetAction = z.enum([
|
|
40
40
|
'place-content',
|
|
41
|
-
'keep-template-content',
|
|
42
|
-
'keep-template-label',
|
|
43
|
-
'select-template-option',
|
|
44
|
-
'exclude-source',
|
|
45
|
-
'review-source',
|
|
46
|
-
]);
|
|
47
|
-
const targetActions = new Set([
|
|
48
|
-
'place-content',
|
|
49
|
-
'keep-template-content',
|
|
50
41
|
'keep-template-label',
|
|
51
42
|
'select-template-option',
|
|
43
|
+
]).describe('Business action: place-content moves the current source content into a target content position; keep-template-label preserves the target label and structure while migrating a uniquely identified current field value; select-template-option marks the target option represented by the current source fact.');
|
|
44
|
+
const targetActions = new Set(migrationQueryTargetAction.options);
|
|
45
|
+
|
|
46
|
+
const choiceReference = z.string().trim().regex(/^[ST][1-9][0-9]*-[0-9a-f]{8}$/);
|
|
47
|
+
const alternativeReference = z.string().trim().regex(/^S[1-9][0-9]*-[PLO][1-9][0-9]*-[0-9a-f]{8}$/);
|
|
48
|
+
|
|
49
|
+
const terminalMigrationChoiceInput = z.object({
|
|
50
|
+
sourceRef: choiceReference,
|
|
51
|
+
action: z.enum(['exclude-source', 'review-source']),
|
|
52
|
+
}).strict();
|
|
53
|
+
|
|
54
|
+
const migrationChoiceInput = z.union([
|
|
55
|
+
z.object({ alternativeRef: alternativeReference }).strict(),
|
|
56
|
+
terminalMigrationChoiceInput,
|
|
52
57
|
]);
|
|
53
|
-
const terminalActions = new Set(['exclude-source', 'review-source']);
|
|
54
|
-
|
|
55
|
-
const migrationChoiceInput = z.object({
|
|
56
|
-
sourceChoiceId: z.string().trim().min(1),
|
|
57
|
-
action: migrationAction,
|
|
58
|
-
targetChoiceId: z.string().trim().min(1).optional(),
|
|
59
|
-
cardinality: z.enum(['one', 'all']).optional(),
|
|
60
|
-
}).strict().superRefine((choice, context) => {
|
|
61
|
-
if (targetActions.has(choice.action) && !choice.targetChoiceId) {
|
|
62
|
-
context.addIssue({ code: 'custom', path: ['targetChoiceId'], message: `${choice.action} requires targetChoiceId` });
|
|
63
|
-
}
|
|
64
|
-
if (terminalActions.has(choice.action) && choice.targetChoiceId) {
|
|
65
|
-
context.addIssue({ code: 'custom', path: ['targetChoiceId'], message: `${choice.action} forbids targetChoiceId` });
|
|
66
|
-
}
|
|
67
|
-
if (choice.cardinality === 'all' && !terminalActions.has(choice.action)) {
|
|
68
|
-
context.addIssue({ code: 'custom', path: ['cardinality'], message: 'cardinality all is limited to terminal actions' });
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
58
|
|
|
72
59
|
const templateCleanupInput = z.object({
|
|
73
|
-
|
|
60
|
+
targetRef: choiceReference,
|
|
74
61
|
scope: z.enum(['cell', 'row']),
|
|
75
62
|
}).strict();
|
|
76
63
|
|
|
@@ -79,7 +66,7 @@ const templateMigrationInput = z.object({
|
|
|
79
66
|
baseline: pathInput.describe('Path to the selected current baseline DOCX.'),
|
|
80
67
|
output: pathInput.describe('Path to the migrated output DOCX.'),
|
|
81
68
|
receiptOutput: pathInput.describe('New JSON receipt artifact path. Existing files are never overwritten.'),
|
|
82
|
-
choices: z.array(migrationChoiceInput).describe('Exactly one business choice for every source
|
|
69
|
+
choices: z.array(migrationChoiceInput).describe('Exactly one business choice for every source ref. Targeted choices use an alternativeRef returned by the query tool. Terminal exclusions and genuine local review use a sourceRef.'),
|
|
83
70
|
templateCleanup: z.array(templateCleanupInput).optional().describe('Optional baseline-owned placeholders or example rows to clear.'),
|
|
84
71
|
}).strict();
|
|
85
72
|
|
|
@@ -99,6 +86,16 @@ const migrationChoiceOutput = z.object({
|
|
|
99
86
|
allowedActions: z.array(z.string()),
|
|
100
87
|
}).strict();
|
|
101
88
|
|
|
89
|
+
const migrationPublicChoiceOutput = migrationChoiceOutput.omit({ id: true }).extend({
|
|
90
|
+
ref: choiceReference,
|
|
91
|
+
}).strict();
|
|
92
|
+
|
|
93
|
+
const migrationAlternativeOutput = z.object({
|
|
94
|
+
ref: alternativeReference,
|
|
95
|
+
action: migrationQueryTargetAction,
|
|
96
|
+
target: migrationPublicChoiceOutput,
|
|
97
|
+
}).strict();
|
|
98
|
+
|
|
102
99
|
const migrationCatalog = z.object({
|
|
103
100
|
schema: z.string(),
|
|
104
101
|
pass: z.boolean(),
|
|
@@ -209,13 +206,6 @@ const migrationTargetPage = z.object({
|
|
|
209
206
|
targets: z.array(migrationChoiceOutput),
|
|
210
207
|
}).strict();
|
|
211
208
|
|
|
212
|
-
const migrationTargetAction = z.enum([
|
|
213
|
-
'place-content',
|
|
214
|
-
'keep-template-content',
|
|
215
|
-
'keep-template-label',
|
|
216
|
-
'select-template-option',
|
|
217
|
-
]);
|
|
218
|
-
|
|
219
209
|
const migrationQueryDocuments = {
|
|
220
210
|
source: pathInput.describe('Path to the current source DOCX used by docx_list_migration_choices.'),
|
|
221
211
|
baseline: pathInput.describe('Path to the same selected current baseline DOCX used by docx_list_migration_choices.'),
|
|
@@ -224,7 +214,7 @@ const migrationQueryDocuments = {
|
|
|
224
214
|
const boundedOffset = z.number().int().nonnegative().optional().describe('Zero-based result offset. Defaults to 0.');
|
|
225
215
|
const boundedLimit = z.number().int().min(1).max(10).optional().describe('Maximum results to return. Defaults to 10 and cannot exceed 10.');
|
|
226
216
|
|
|
227
|
-
const migrationChoiceQueryInput = z.
|
|
217
|
+
const migrationChoiceQueryInput = z.union([
|
|
228
218
|
z.object({
|
|
229
219
|
...migrationQueryDocuments,
|
|
230
220
|
view: z.literal('sources'),
|
|
@@ -234,8 +224,8 @@ const migrationChoiceQueryInput = z.discriminatedUnion('view', [
|
|
|
234
224
|
z.object({
|
|
235
225
|
...migrationQueryDocuments,
|
|
236
226
|
view: z.literal('targets'),
|
|
237
|
-
|
|
238
|
-
action:
|
|
227
|
+
sourceRef: choiceReference.describe('Short source reference returned by the sources view.'),
|
|
228
|
+
action: migrationQueryTargetAction.optional().describe('Optional business-action filter. Choose the action from scenario meaning, then use this filter to inspect only compatible targets. Omit it only when comparing the three documented action meanings.'),
|
|
239
229
|
text: z.string().trim().min(1).optional().describe('Optional literal case-insensitive text to find in target visible text or context.'),
|
|
240
230
|
offset: boundedOffset,
|
|
241
231
|
limit: boundedLimit,
|
|
@@ -255,9 +245,9 @@ const migrationChoiceQueryOutput = z.object({
|
|
|
255
245
|
sourceSha256: z.string(),
|
|
256
246
|
baselineSha256: z.string(),
|
|
257
247
|
view: z.enum(['sources', 'targets', 'cleanup']),
|
|
258
|
-
action:
|
|
259
|
-
source:
|
|
260
|
-
items: z.array(
|
|
248
|
+
action: migrationQueryTargetAction.nullable(),
|
|
249
|
+
source: migrationPublicChoiceOutput.nullable(),
|
|
250
|
+
items: z.array(z.union([migrationPublicChoiceOutput, migrationAlternativeOutput])),
|
|
261
251
|
page: migrationQueryPage,
|
|
262
252
|
}).strict();
|
|
263
253
|
|
|
@@ -305,7 +295,7 @@ const tools = [
|
|
|
305
295
|
},
|
|
306
296
|
{
|
|
307
297
|
name: 'docx_query_migration_choices',
|
|
308
|
-
description: 'Query current template-migration alternatives without reading the catalog artifact.
|
|
298
|
+
description: 'Query current template-migration alternatives without reading the catalog artifact. place-content moves current content into a target content position; keep-template-label preserves the target label and structure while migrating a uniquely identified current field value; select-template-option marks a target option represented by the current source fact. Choose the action from scenario meaning and filter by it before paging compatible targets. Target results bind the action and target into one alternativeRef; result order helps discovery but never makes the business choice.',
|
|
309
299
|
inputSchema: migrationChoiceQueryInput,
|
|
310
300
|
outputSchema: migrationChoiceQueryOutput,
|
|
311
301
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
@@ -313,7 +303,7 @@ const tools = [
|
|
|
313
303
|
},
|
|
314
304
|
{
|
|
315
305
|
name: 'docx_migrate_template',
|
|
316
|
-
description: 'Migrate a current DOCX into the selected baseline from one complete batch of business choices.
|
|
306
|
+
description: 'Migrate a current DOCX into the selected baseline from one complete batch of business choices. For targeted choices, submit one alternativeRef returned by docx_query_migration_choices. Exclusion and local review use a sourceRef plus their terminal action. The tool derives all document values, coordinates, plans, and edits.',
|
|
317
307
|
inputSchema: templateMigrationInput,
|
|
318
308
|
outputSchema: migrationReceiptOutput('docx_migrate_template'),
|
|
319
309
|
handler: docxMigrateTemplate,
|
|
@@ -466,57 +456,99 @@ async function docxQueryMigrationChoices(args) {
|
|
|
466
456
|
|
|
467
457
|
let source = null;
|
|
468
458
|
let action = null;
|
|
469
|
-
let branch;
|
|
470
|
-
let sourceChoiceId = '-';
|
|
471
459
|
if (args.view === 'cleanup') {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
460
|
+
const targetResult = await loadMigrationTargets({
|
|
461
|
+
sourcePath,
|
|
462
|
+
baselinePath,
|
|
463
|
+
sourceChoiceId: '-',
|
|
464
|
+
branch: 'baseline-clear',
|
|
465
|
+
text: args.text,
|
|
466
|
+
});
|
|
467
|
+
const orderedTargets = validateAndOrderMigrationTargets(catalog, null, targetResult.targets);
|
|
468
|
+
return migrationQueryResult({
|
|
469
|
+
runtime: targetResult.runtime,
|
|
470
|
+
catalog,
|
|
471
|
+
view: 'cleanup',
|
|
472
|
+
action: null,
|
|
473
|
+
source: null,
|
|
474
|
+
items: orderedTargets.slice(offset, offset + limit),
|
|
475
|
+
offset,
|
|
476
|
+
total: orderedTargets.length,
|
|
477
|
+
});
|
|
484
478
|
}
|
|
485
479
|
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
480
|
+
const requestedSourceId = choiceIdFromRef(catalog, catalog.sources, args.sourceRef, 'S', 'source');
|
|
481
|
+
source = catalog.sources.find(item => item.id === requestedSourceId) ?? null;
|
|
482
|
+
if (!source) {
|
|
483
|
+
throw Object.assign(new Error(`Unknown migration source: ${args.sourceRef}`), { code: -32602 });
|
|
484
|
+
}
|
|
485
|
+
action = args.action ?? null;
|
|
486
|
+
const actions = action
|
|
487
|
+
? [action]
|
|
488
|
+
: [...targetActions].filter(candidate => source.allowedActions.includes(candidate));
|
|
489
|
+
const pages = await Promise.all(actions.map(async candidate => {
|
|
490
|
+
if (!source.allowedActions.includes(candidate)) {
|
|
491
|
+
throw Object.assign(new Error(`Action ${candidate} is not allowed for ${source.id}`), { code: -32602 });
|
|
492
|
+
}
|
|
493
|
+
const result = await loadMigrationTargets({
|
|
494
|
+
sourcePath,
|
|
495
|
+
baselinePath,
|
|
496
|
+
sourceChoiceId: source.id,
|
|
497
|
+
branch: migrationTargetBranch(candidate, source.kind),
|
|
498
|
+
text: args.text,
|
|
499
|
+
});
|
|
500
|
+
return {
|
|
501
|
+
action: candidate,
|
|
502
|
+
runtime: result.runtime,
|
|
503
|
+
targets: validateAndOrderMigrationTargets(catalog, source, result.targets),
|
|
504
|
+
};
|
|
505
|
+
}));
|
|
506
|
+
const catalogOrder = new Map(catalog.targets.map((item, index) => [item.id, index]));
|
|
507
|
+
const actionOrder = new Map([...targetActions].map((item, index) => [item, index]));
|
|
508
|
+
const alternatives = pages.flatMap(page => page.targets.map(target => ({ action: page.action, target })))
|
|
509
|
+
.sort((left, right) => {
|
|
510
|
+
const relevance = compareRelevance(
|
|
511
|
+
migrationChoiceRelevance(source, right.target),
|
|
512
|
+
migrationChoiceRelevance(source, left.target));
|
|
513
|
+
return relevance
|
|
514
|
+
|| catalogOrder.get(left.target.id) - catalogOrder.get(right.target.id)
|
|
515
|
+
|| actionOrder.get(left.action) - actionOrder.get(right.action);
|
|
516
|
+
})
|
|
517
|
+
.map(item => ({
|
|
518
|
+
ref: migrationAlternativeRef(catalog, source, item.action, item.target),
|
|
519
|
+
action: item.action,
|
|
520
|
+
target: publicMigrationChoice(catalog, item.target, 'T'),
|
|
521
|
+
}));
|
|
522
|
+
return migrationQueryResult({
|
|
523
|
+
runtime: pages[0]?.runtime ?? commandRuntime(catalogResult),
|
|
524
|
+
catalog,
|
|
525
|
+
view: 'targets',
|
|
526
|
+
action,
|
|
527
|
+
source,
|
|
528
|
+
items: alternatives.slice(offset, offset + limit),
|
|
529
|
+
offset,
|
|
530
|
+
total: alternatives.length,
|
|
531
|
+
alternatives: true,
|
|
492
532
|
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function validateAndOrderMigrationTargets(catalog, source, targets) {
|
|
493
536
|
const catalogTargets = new Map(catalog.targets.map(item => [item.id, item]));
|
|
494
|
-
for (const target of
|
|
537
|
+
for (const target of targets) {
|
|
495
538
|
const current = catalogTargets.get(target.id);
|
|
496
539
|
if (!current || !isDeepStrictEqual(current, target)) {
|
|
497
540
|
throw new Error(`Migration target ${target.id} is not bound to the current catalog`);
|
|
498
541
|
}
|
|
499
542
|
}
|
|
500
543
|
const catalogOrder = new Map(catalog.targets.map((item, index) => [item.id, index]));
|
|
501
|
-
|
|
502
|
-
? [...
|
|
544
|
+
return source
|
|
545
|
+
? [...targets].sort((left, right) => {
|
|
503
546
|
const relevance = compareRelevance(
|
|
504
547
|
migrationChoiceRelevance(source, right),
|
|
505
548
|
migrationChoiceRelevance(source, left));
|
|
506
549
|
return relevance || catalogOrder.get(left.id) - catalogOrder.get(right.id);
|
|
507
550
|
})
|
|
508
|
-
: [...
|
|
509
|
-
const items = orderedTargets.slice(offset, offset + limit);
|
|
510
|
-
return migrationQueryResult({
|
|
511
|
-
runtime: targetResult.runtime,
|
|
512
|
-
catalog,
|
|
513
|
-
view: args.view,
|
|
514
|
-
action,
|
|
515
|
-
source,
|
|
516
|
-
items,
|
|
517
|
-
offset,
|
|
518
|
-
total: orderedTargets.length,
|
|
519
|
-
});
|
|
551
|
+
: [...targets].sort((left, right) => catalogOrder.get(left.id) - catalogOrder.get(right.id));
|
|
520
552
|
}
|
|
521
553
|
|
|
522
554
|
async function loadMigrationTargets({ sourcePath, baselinePath, sourceChoiceId, branch, text }) {
|
|
@@ -632,7 +664,82 @@ function migrationTargetBranch(action, sourceKind) {
|
|
|
632
664
|
throw new Error(`Unsupported migration target action: ${action}`);
|
|
633
665
|
}
|
|
634
666
|
|
|
635
|
-
function
|
|
667
|
+
function catalogReferenceToken(catalog) {
|
|
668
|
+
return createHash('sha256')
|
|
669
|
+
.update(`${catalog.sourceSha256}\n${catalog.baselineSha256}`)
|
|
670
|
+
.digest('hex')
|
|
671
|
+
.slice(0, 8);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function choiceRef(catalog, items, id, prefix) {
|
|
675
|
+
const index = items.findIndex(item => item.id === id);
|
|
676
|
+
if (index < 0) throw new Error(`Migration choice is not bound to the current catalog: ${id}`);
|
|
677
|
+
return `${prefix}${index + 1}-${catalogReferenceToken(catalog)}`;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function choiceIdFromRef(catalog, items, ref, prefix, label) {
|
|
681
|
+
if (!ref.startsWith(prefix)) {
|
|
682
|
+
throw invalidMigrationInput(`invalid migration ${label} ref: ${ref}`);
|
|
683
|
+
}
|
|
684
|
+
const [ordinal, token] = ref.slice(1).split('-');
|
|
685
|
+
if (token !== catalogReferenceToken(catalog)) {
|
|
686
|
+
throw invalidMigrationInput(`stale migration ${label} ref: ${ref}`);
|
|
687
|
+
}
|
|
688
|
+
const item = items[Number(ordinal) - 1];
|
|
689
|
+
if (!item) throw invalidMigrationInput(`unknown migration ${label} ref: ${ref}`);
|
|
690
|
+
return item.id;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const migrationActionCodes = new Map([
|
|
694
|
+
['place-content', 'P'],
|
|
695
|
+
['keep-template-label', 'L'],
|
|
696
|
+
['select-template-option', 'O'],
|
|
697
|
+
]);
|
|
698
|
+
const migrationActionsByCode = new Map([...migrationActionCodes].map(([action, code]) => [code, action]));
|
|
699
|
+
|
|
700
|
+
function migrationAlternativeRef(catalog, source, action, target) {
|
|
701
|
+
const sourceOrdinal = catalog.sources.findIndex(item => item.id === source.id) + 1;
|
|
702
|
+
const targetOrdinal = catalog.targets.findIndex(item => item.id === target.id) + 1;
|
|
703
|
+
const code = migrationActionCodes.get(action);
|
|
704
|
+
if (sourceOrdinal < 1 || targetOrdinal < 1 || !code) {
|
|
705
|
+
throw new Error('Migration alternative is not bound to the current catalog');
|
|
706
|
+
}
|
|
707
|
+
return `S${sourceOrdinal}-${code}${targetOrdinal}-${migrationAlternativeToken(catalog, source, action, target)}`;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function migrationAlternativeToken(catalog, source, action, target) {
|
|
711
|
+
return createHash('sha256')
|
|
712
|
+
.update(`${catalog.sourceSha256}\n${catalog.baselineSha256}\n${source.id}\n${action}\n${target.id}`)
|
|
713
|
+
.digest('hex')
|
|
714
|
+
.slice(0, 8);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function migrationChoiceFromAlternativeRef(catalog, ref) {
|
|
718
|
+
const match = /^S([1-9][0-9]*)-([PLO])([1-9][0-9]*)-([0-9a-f]{8})$/.exec(ref);
|
|
719
|
+
if (!match) throw invalidMigrationInput(`invalid migration alternative ref: ${ref}`);
|
|
720
|
+
const source = catalog.sources[Number(match[1]) - 1];
|
|
721
|
+
const target = catalog.targets[Number(match[3]) - 1];
|
|
722
|
+
const action = migrationActionsByCode.get(match[2]);
|
|
723
|
+
if (!source || !target || !action) {
|
|
724
|
+
throw invalidMigrationInput(`unknown migration alternative ref: ${ref}`);
|
|
725
|
+
}
|
|
726
|
+
if (match[4] !== migrationAlternativeToken(catalog, source, action, target)) {
|
|
727
|
+
throw invalidMigrationInput(`stale or invalid migration alternative ref: ${ref}`);
|
|
728
|
+
}
|
|
729
|
+
return { sourceChoiceId: source.id, action, targetChoiceId: target.id };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function publicMigrationChoice(catalog, choice, prefix) {
|
|
733
|
+
const { id, ...visible } = choice;
|
|
734
|
+
const items = prefix === 'S' ? catalog.sources : catalog.targets;
|
|
735
|
+
return {
|
|
736
|
+
...visible,
|
|
737
|
+
allowedActions: visible.allowedActions.filter(action => action !== 'keep-template-content'),
|
|
738
|
+
ref: choiceRef(catalog, items, id, prefix),
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function migrationQueryResult({ runtime, catalog, view, action, source, items, offset, total, alternatives = false }) {
|
|
636
743
|
return {
|
|
637
744
|
tool: 'docx_query_migration_choices',
|
|
638
745
|
runtime,
|
|
@@ -640,8 +747,10 @@ function migrationQueryResult({ runtime, catalog, view, action, source, items, o
|
|
|
640
747
|
baselineSha256: catalog.baselineSha256,
|
|
641
748
|
view,
|
|
642
749
|
action,
|
|
643
|
-
source,
|
|
644
|
-
items
|
|
750
|
+
source: source ? publicMigrationChoice(catalog, source, 'S') : null,
|
|
751
|
+
items: alternatives
|
|
752
|
+
? items
|
|
753
|
+
: items.map(item => publicMigrationChoice(catalog, item, view === 'sources' ? 'S' : 'T')),
|
|
645
754
|
page: {
|
|
646
755
|
offset,
|
|
647
756
|
returned: items.length,
|
|
@@ -659,6 +768,53 @@ async function docxVerifyMigration(args) {
|
|
|
659
768
|
return runTemplateMigrationCommand('docx_verify_migration', 'verify-template-migration', args);
|
|
660
769
|
}
|
|
661
770
|
|
|
771
|
+
function invalidMigrationInput(message) {
|
|
772
|
+
return Object.assign(new Error(message), { code: -32602 });
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function completeMigrationChoices(catalog, choices) {
|
|
776
|
+
const sources = new Map(catalog.sources.map(source => [source.id, source]));
|
|
777
|
+
const seen = new Set();
|
|
778
|
+
const completed = choices.map(rawChoice => {
|
|
779
|
+
const choice = rawChoice.alternativeRef
|
|
780
|
+
? migrationChoiceFromAlternativeRef(catalog, rawChoice.alternativeRef)
|
|
781
|
+
: {
|
|
782
|
+
sourceChoiceId: choiceIdFromRef(catalog, catalog.sources, rawChoice.sourceRef, 'S', 'source'),
|
|
783
|
+
action: rawChoice.action,
|
|
784
|
+
};
|
|
785
|
+
const { sourceChoiceId } = choice;
|
|
786
|
+
const source = sources.get(sourceChoiceId);
|
|
787
|
+
if (!source) throw invalidMigrationInput(`unknown migration source id: ${sourceChoiceId}`);
|
|
788
|
+
if (seen.has(sourceChoiceId)) throw invalidMigrationInput(`duplicate migration source id: ${sourceChoiceId}`);
|
|
789
|
+
seen.add(sourceChoiceId);
|
|
790
|
+
if (!source.allowedActions.includes(choice.action)) {
|
|
791
|
+
throw invalidMigrationInput(`migration action ${choice.action} is not allowed for source id: ${sourceChoiceId}`);
|
|
792
|
+
}
|
|
793
|
+
return source.requiredCardinality === 'all'
|
|
794
|
+
? { ...choice, cardinality: 'all' }
|
|
795
|
+
: choice;
|
|
796
|
+
});
|
|
797
|
+
const missing = [...sources.keys()].filter(sourceChoiceId => !seen.has(sourceChoiceId));
|
|
798
|
+
if (missing.length > 0) {
|
|
799
|
+
throw invalidMigrationInput(`migration choices must cover every source id; missing ${missing.length}`);
|
|
800
|
+
}
|
|
801
|
+
return completed;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function completeTemplateCleanup(catalog, cleanup, choices) {
|
|
805
|
+
const targets = new Map(catalog.targets.map(target => [target.id, target]));
|
|
806
|
+
const claimedTargets = new Set(choices.flatMap(choice => choice.targetChoiceId ? [choice.targetChoiceId] : []));
|
|
807
|
+
const seen = new Set();
|
|
808
|
+
return cleanup.map(rawCleanup => {
|
|
809
|
+
const targetChoiceId = choiceIdFromRef(catalog, catalog.targets, rawCleanup.targetRef, 'T', 'target');
|
|
810
|
+
if (!seen.add(targetChoiceId)) throw invalidMigrationInput(`duplicate migration cleanup target id: ${targetChoiceId}`);
|
|
811
|
+
if (!targets.get(targetChoiceId)?.allowedActions.includes('template-cleanup')) {
|
|
812
|
+
throw invalidMigrationInput(`migration cleanup is not allowed for target id: ${targetChoiceId}`);
|
|
813
|
+
}
|
|
814
|
+
return { targetChoiceId, scope: rawCleanup.scope };
|
|
815
|
+
}).filter(cleanupChoice => !claimedTargets.has(cleanupChoice.targetChoiceId));
|
|
816
|
+
}
|
|
817
|
+
|
|
662
818
|
async function runTemplateMigrationCommand(tool, command, args) {
|
|
663
819
|
const source = requireString(args.source, 'source');
|
|
664
820
|
const baseline = requireString(args.baseline, 'baseline');
|
|
@@ -666,16 +822,26 @@ async function runTemplateMigrationCommand(tool, command, args) {
|
|
|
666
822
|
if (!Array.isArray(args.choices)) {
|
|
667
823
|
throw Object.assign(new Error('choices must be an array'), { code: -32602 });
|
|
668
824
|
}
|
|
825
|
+
const catalogResult = await runJsonCandidateChain(docxCandidates, ['list-template-migration-choices', source, baseline]);
|
|
826
|
+
const catalog = migrationCatalog.parse(catalogResult.json);
|
|
827
|
+
const choices = completeMigrationChoices(catalog, args.choices);
|
|
828
|
+
const templateCleanup = Array.isArray(args.templateCleanup)
|
|
829
|
+
? completeTemplateCleanup(catalog, args.templateCleanup, choices)
|
|
830
|
+
: [];
|
|
669
831
|
const payload = {
|
|
670
832
|
schema: 'tiwater.docx.template-migration-business-choices/v1',
|
|
671
|
-
choices
|
|
672
|
-
...(
|
|
833
|
+
choices,
|
|
834
|
+
...(templateCleanup.length > 0 ? { templateCleanup } : {}),
|
|
673
835
|
};
|
|
674
836
|
return withTempJsonFile(payload, async choicesPath => {
|
|
675
837
|
const result = await runJsonCandidateChain(
|
|
676
838
|
docxCandidates,
|
|
677
839
|
[command, source, baseline, choicesPath, output],
|
|
678
840
|
{ allowedExitCodes: [0, 1] });
|
|
841
|
+
if (result.json === null) {
|
|
842
|
+
const detail = result.stderr.trim() || result.stdout.trim() || 'no diagnostic output';
|
|
843
|
+
throw new Error(`${result.command} ${command} returned no JSON receipt (exit ${result.code}): ${detail}`);
|
|
844
|
+
}
|
|
679
845
|
const receipt = migrationReceipt.parse(result.json);
|
|
680
846
|
return {
|
|
681
847
|
tool,
|