openyida 2026.10.20-2 → 2026.10.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +4 -1
  2. package/bin/yida.js +9 -0
  3. package/lib/app/cool-card.js +5 -0
  4. package/lib/core/command-manifest.js +9 -4
  5. package/lib/core/locales/en.js +12 -1
  6. package/lib/core/locales/zh.js +12 -1
  7. package/lib/core/sample.js +1 -0
  8. package/lib/core/update.js +1 -1
  9. package/lib/feedback/feedback.js +1 -0
  10. package/lib/feedback/github.js +130 -0
  11. package/lib/print-template/builder.js +232 -0
  12. package/lib/print-template/codes.js +46 -0
  13. package/lib/print-template/contract.js +31 -0
  14. package/lib/print-template/detail.js +36 -0
  15. package/lib/print-template/model.js +97 -0
  16. package/lib/print-template/print-template.js +302 -0
  17. package/lib/print-template/scenes.js +29 -0
  18. package/lib/samples/yida-print-template/approval.json +14 -0
  19. package/lib/samples/yida-print-template/codes.json +12 -0
  20. package/lib/samples/yida-print-template/delivery.json +14 -0
  21. package/lib/samples/yida-print-template/order.json +14 -0
  22. package/lib/samples/yida-print-template/quotation.json +13 -0
  23. package/lib/samples/yida-print-template/receipt.json +13 -0
  24. package/package.json +1 -1
  25. package/yida-skills/SKILL.md +3 -2
  26. package/yida-skills/skills/yida-cool-card/SKILL.md +3 -1
  27. package/yida-skills/skills/yida-feedback/SKILL.md +1 -1
  28. package/yida-skills/skills/yida-feedback/references/github-contribution.md +19 -2
  29. package/yida-skills/skills/yida-print-template/SKILL.md +134 -0
  30. package/yida-skills/skills/yida-print-template/references/detail.md +40 -0
  31. package/yida-skills/skills/yida-print-template/references/guide.md +35 -0
  32. package/yida-skills/skills/yida-print-template/references/scenarios.md +72 -0
  33. package/yida-skills/skills-index.json +14 -1
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ const { fail, object, unwrap } = require('./model');
4
+ const FORMATS = ['qrcode', 'barcode'];
5
+ const PREFIXES = ['textField', 'numberField', 'selectField', 'radioField', 'serialNo'];
6
+ const CODE_EDITIONS = new Set(['pro', 'vip', 'isv', 'hybrid_cloud']);
7
+
8
+ /** Match the native editor's field-code whitelist, without enabling unrelated field types. */
9
+ function eligible(field) {
10
+ return typeof field?.key === 'string' && PREFIXES.some(prefix => field.key.startsWith(prefix))
11
+ && !/\.(qrcode|barcode)$/.test(field.key);
12
+ }
13
+
14
+ /** Read the existing edition and rollout gate; never change entitlements or gray configuration. */
15
+ async function capabilities(client) {
16
+ const results = await Promise.allSettled([
17
+ client.get('/query/commodity/getSimpleCommodityInfo.json', {}).then(response => unwrap(response, 'getSimpleCommodityInfo')),
18
+ client.get('/query/compromiseDing/orgInGray.json', { grayKey: 'yida_fe_print_support_qrcode' }).then(response => unwrap(response, 'orgInGray')),
19
+ ]);
20
+ const edition = results[0].status === 'fulfilled' && object(results[0].value)
21
+ ? String(results[0].value.corpVersion || '').toLowerCase() : '';
22
+ const gray = results[1].status === 'fulfilled' && typeof results[1].value === 'boolean' ? results[1].value : null;
23
+ const enabled = CODE_EDITIONS.has(edition) && gray === true;
24
+ return { enabled, edition, gray, reason: enabled ? null : !edition ? 'edition_unverified'
25
+ : !CODE_EDITIONS.has(edition) ? 'edition_not_supported' : gray === false ? 'feature_not_enabled' : 'feature_unverified' };
26
+ }
27
+
28
+ /** Derive the native .qrcode/.barcode image key from a live, eligible field. */
29
+ function transform(field, format) {
30
+ if (!FORMATS.includes(format) || !eligible(field)) { fail('UNSUPPORTED', `field code: ${field?.label || field?.key}`); }
31
+ if (!field.codeFormats?.includes(format)) {
32
+ fail('CODE_UNAVAILABLE', `${format}: ${field.codeUnavailableReason || 'capability_unverified'}`);
33
+ }
34
+ return { ...field, key: `${field.key}.${format}`, label: `${field.label}.${format === 'qrcode' ? '二维码' : '条形码'}`, type: 'img', codeFormat: format };
35
+ }
36
+
37
+ /** Validate derived keys in native exports as strictly as authored code blocks. */
38
+ function resolveNative(key, dictionary) {
39
+ const match = typeof key === 'string' && /^(.*)\.(qrcode|barcode)$/.exec(key);
40
+ if (!match) { return null; }
41
+ const field = dictionary.find(item => item.key === match[1]);
42
+ if (!field) { fail('FIELD_REFERENCE', match[1]); }
43
+ return transform(field, match[2]);
44
+ }
45
+
46
+ module.exports = { FORMATS, eligible, capabilities, transform, resolveNative };
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ const readActions = ['list', 'inspect', 'fields', 'files'];
4
+ const writeActions = ['create', 'copy', 'save', 'restore', 'settings', 'status', 'order', 'render'];
5
+ const usage = 'print-template <list|inspect|fields|build|create|copy|save|restore|settings|status|order|render|files> <appType> <formUuid> [templateId] [--config <file>] [--scene <order|quotation|delivery|receipt|approval|codes>] [--bindings <file>] [--name <title>] [--expected-hash <hash>] [--backup <file>] [--out <file>] [--status START|STOP] [--instance <id>] [--page <n>] [--size <n>] [--allow-permission-change] [--json] | print-template <list|render> --detail-url <url> [--template-id <id>] [--json]';
6
+
7
+ /** Shared manifest metadata keeps ordinary and managed agents on the same native contract. */
8
+ const printTemplateCommand = ['print-template', ['print-template'], usage, 'print_template.description', {
9
+ output: 'json',
10
+ managedRun: { scope: 'actions', actions: {
11
+ ...Object.fromEntries([...readActions, 'build'].map(action => [action, { scope: 'app', appPosition: 1 }])),
12
+ ...Object.fromEntries(writeActions.filter(action => action !== 'render').map(action => [action,
13
+ { scope: 'app', appPosition: 1, mutation: { operation: 'update_form', formUuidPosition: 2 } }])),
14
+ // PDF generation is explicit and may consume storage; managed app runs cannot invoke it.
15
+ render: { scope: 'denied', reason: 'native_pdf_generation' },
16
+ } },
17
+ examples: ['openyida print-template list --detail-url "https://tenant.aliwork.com/APP_XXX/formDetail/FORM_XXX?formInstId=FINST_XXX" --json',
18
+ 'openyida print-template render --detail-url "https://tenant.aliwork.com/APP_XXX/formDetail/FORM_XXX?formInstId=FINST_XXX" --template-id 123 --json',
19
+ 'openyida print-template fields APP_XXX FORM_XXX --json',
20
+ 'openyida print-template build APP_XXX FORM_XXX --scene quotation --bindings quote-fields.json --out quote.json --json',
21
+ 'openyida print-template create APP_XXX FORM_XXX --name "报价单" --config quote.json --json',
22
+ 'openyida print-template save APP_XXX FORM_XXX 123 --config quote.json --expected-hash <inspect-hash> --json'],
23
+ notes: ['Native rich-text YiDA printing only; Word, DingDang and government printing use different editors. build compiles a named-field document or validates a complete native body, without saving it. build --scene selects one of six reusable samples and --bindings maps semantic roles to actual field names/IDs before validation. create --config validates first, creates and saves content; failures after creation expose createdTemplateId and must be resumed with inspect/save instead of repeated create. qrcode/barcode blocks and field/column render options use native dynamic image keys, with validated field prefixes, current edition and feature availability. fields reports codeFormats; barcode values must follow native character limits and are never silently rewritten. inspect --out exports a full snapshot; restore restores its content only to the same target. save is immediately effective, with no separate publish action. Hashes are required for updates; body saves also send the native gmtModified concurrency token when present. Every remote mutation backs up first, never retries, and verifies readback. Uncertain create/copy results must be inspected before retrying. settings preserves untranslated locales and does not change roleData unless supplied with --allow-permission-change. Built-in default template body cannot be edited. render requests one native PDF and returns a file ID, not proof of generation or physical printing; files lists generation records. list --instance and --detail-url use the runtime record-authorized template list instead of the administration list; render uses that same list and never reads an admin-only template body. URL mode only accepts known native detail routes and the authenticated origin; template choice is explicit. It prints record data through native templates, not arbitrary website pixels. No edition/quota changes. Custom CSS, fixed pages, paper size, margins, headers/footers and automatic totals are not guaranteed by this legacy editor.'],
24
+ }];
25
+
26
+ const printTemplateEffects = { mutates_yida: true, mutates_local: true, read_actions: readActions,
27
+ mutating_actions: ['build', ...writeActions], note: 'build only writes an optional local output; inspect/fields write local exports only with --out.' };
28
+ const printTemplatePermission = { preauthorized_actions: ['build', 'create', 'copy', 'save', 'restore', 'status', 'order'],
29
+ ask_actions: ['settings', 'render'] };
30
+
31
+ module.exports = { printTemplateCommand, printTemplateEffects, printTemplatePermission, usage };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const { fail } = require('./model');
4
+
5
+ /** Parse native record links as identifiers only; never fetch arbitrary URLs or forward their query. */
6
+ function parse(value) {
7
+ let url;
8
+ try { url = new URL(value); } catch (_) { fail('INVALID_CONFIG', 'detail URL'); }
9
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.hash) { fail('INVALID_CONFIG', 'native detail URL'); }
10
+ for (const key of ['appType', 'formUuid', 'formInstId', 'activityId']) {
11
+ if (url.searchParams.getAll(key).length > 1) { fail('INVALID_CONFIG', `duplicate ${key}`); }
12
+ }
13
+ const modern = /^\/(APP_[\w-]+)\/(formDetail|processDetail)\/(FORM[_-][\w-]+)\/?$/.exec(url.pathname);
14
+ const legacy = /^\/alibaba\/(?:web|mobile)\/(APP_[\w-]+)\/inst\/(formEdit|taskDetail)\.html$/.exec(url.pathname);
15
+ if (!modern && !legacy) { fail('UNSUPPORTED', 'native formDetail/processDetail link required'); }
16
+ const appType = (modern || legacy)[1];
17
+ const formUuid = modern ? modern[3] : url.searchParams.get('formUuid');
18
+ const instance = url.searchParams.get('formInstId');
19
+ const activityId = url.searchParams.get('activityId');
20
+ if (!/^FORM[_-][\w-]+$/.test(formUuid || '') || !/^[\w-]+$/.test(instance || '')
21
+ || activityId !== null && !/^[\w-]+$/.test(activityId)
22
+ || url.searchParams.has('appType') && url.searchParams.get('appType') !== appType
23
+ || url.searchParams.has('formUuid') && url.searchParams.get('formUuid') !== formUuid) { fail('INVALID_CONFIG', 'detail target'); }
24
+ return { appType, formUuid, instance, ...(activityId ? { activityId } : {}), origin: url.origin,
25
+ kind: modern ? modern[2] : legacy[2] === 'formEdit' ? 'formDetail' : 'processDetail' };
26
+ }
27
+
28
+ /** Refuse cross-organization routing; authenticate to the link's environment before printing. */
29
+ function verifyOrigin(target, client) {
30
+ const base = client.getAuthRef?.()?.baseUrl;
31
+ let origin;
32
+ try { origin = new URL(base).origin; } catch (_) { fail('TARGET_MISMATCH', 'authenticated origin unavailable'); }
33
+ if (origin !== target.origin) { fail('TARGET_MISMATCH', 'detail URL origin differs from authenticated environment'); }
34
+ }
35
+
36
+ module.exports = { parse, verifyOrigin };
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { CliError } = require('../core/cli-error');
7
+ const { t } = require('../core/i18n');
8
+ const { resolveProjectRoot } = require('../core/utils');
9
+
10
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
11
+
12
+ /** Fail with a stable code; platform failures retain their native diagnostic. */
13
+ function fail(code, detail, details) {
14
+ throw new CliError(t('print_template.error', detail), { code: `PRINT_TEMPLATE_${code}`, details });
15
+ }
16
+
17
+ /** Parse JSON strings used by the native print editor without evaluating them. */
18
+ function json(value, label) {
19
+ try { return typeof value === 'string' ? JSON.parse(value) : value; }
20
+ catch (_) { return fail('INVALID_CONFIG', label); }
21
+ }
22
+
23
+ /** Reject prototype keys recursively, including imported native configurations. */
24
+ function safe(value) {
25
+ if (!value || typeof value !== 'object') { return; }
26
+ for (const key of Object.keys(value)) {
27
+ if (['__proto__', 'constructor', 'prototype'].includes(key)) { fail('INVALID_CONFIG', key); }
28
+ safe(value[key]);
29
+ }
30
+ }
31
+
32
+ /** Normalize translated labels without discarding the original locale object. */
33
+ function label(value) {
34
+ let parsed = value;
35
+ if (typeof value === 'string') {
36
+ try { parsed = JSON.parse(value); } catch (_) { return value; }
37
+ }
38
+ return object(parsed) ? parsed.zh_CN || parsed.en_US || parsed.key || '' : String(parsed ?? '');
39
+ }
40
+
41
+ /** Canonical object key ordering for stale-edit fingerprints. */
42
+ function stable(value) {
43
+ if (Array.isArray(value)) { return value.map(stable); }
44
+ return object(value) ? Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])])) : value;
45
+ }
46
+
47
+ /** Compare authored values while allowing the server to add nullable dictionary metadata. */
48
+ function matches(expected, actual) {
49
+ if (Array.isArray(expected)) { return Array.isArray(actual) && actual.length === expected.length && expected.every((value, i) => matches(value, actual[i])); }
50
+ if (object(expected)) { return object(actual) && Object.entries(expected).every(([key, value]) => matches(value, actual[key])); }
51
+ return expected === actual;
52
+ }
53
+
54
+ /** Normalize only the native content fields; missing content means an unsaved template. */
55
+ function content(raw) {
56
+ if (!object(raw)) { fail('INVALID_RESPONSE', 'template content'); }
57
+ if (!Object.keys(raw).length) { return { termContents: '', termJsonAttr: [], dictionaryList: [], gmtModified: null }; }
58
+ const result = { ...raw, termJsonAttr: json(raw.termJsonAttr ?? [], 'termJsonAttr'),
59
+ dictionaryList: json(raw.dictionaryList ?? [], 'dictionaryList') };
60
+ if (typeof result.termContents !== 'string' || !Array.isArray(result.termJsonAttr) || !Array.isArray(result.dictionaryList)) {
61
+ fail('INVALID_RESPONSE', 'termContents / termJsonAttr / dictionaryList');
62
+ }
63
+ safe(result);
64
+ return result;
65
+ }
66
+
67
+ /** Hash metadata and body together, including the server concurrency timestamp. */
68
+ function hash(snapshot) {
69
+ return crypto.createHash('sha256').update(JSON.stringify(stable({ template: snapshot.template, content: snapshot.content }))).digest('hex');
70
+ }
71
+
72
+ /** Write exports/backups exclusively and keep business configuration private on disk. */
73
+ function write(value, filename, kind = 'backup') {
74
+ const output = filename ? path.resolve(filename) : path.join(resolveProjectRoot().projectRoot,
75
+ '.cache', 'openyida', 'print-template', `${kind}-${Date.now()}-${crypto.randomUUID()}.json`);
76
+ fs.mkdirSync(path.dirname(output), { recursive: true });
77
+ fs.writeFileSync(output, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
78
+ return output;
79
+ }
80
+
81
+ /** Load explicit user-authored input; never consume arbitrary files as instructions. */
82
+ function read(filename) {
83
+ const result = json(fs.readFileSync(path.resolve(filename), 'utf8'), filename);
84
+ safe(result);
85
+ return result;
86
+ }
87
+
88
+ /** Strictly recognize native success envelopes, including auth sentinels and false payloads. */
89
+ function unwrap(response, endpoint) {
90
+ if (!response || response.success !== true || response.__needLogin || response.__csrfExpired || response.content === false) {
91
+ fail('API_ERROR', response?.errorMsg || response?.message || endpoint,
92
+ { endpoint, platformErrorCode: response?.errorCode, needLogin: !!response?.__needLogin });
93
+ }
94
+ return response.content;
95
+ }
96
+
97
+ module.exports = { object, fail, json, safe, label, stable, matches, content, hash, write, read, unwrap };
@@ -0,0 +1,302 @@
1
+ 'use strict';
2
+
3
+ const { isDeepStrictEqual } = require('util');
4
+ const { t } = require('../core/i18n');
5
+ const { CliError } = require('../core/cli-error');
6
+ const { createYidaClient } = require('../core/yida-client');
7
+ const { buildYidaTitleI18n } = require('../core/yida-i18n');
8
+ const m = require('./model');
9
+ const builder = require('./builder');
10
+ const codes = require('./codes');
11
+ const scenes = require('./scenes');
12
+ const detail = require('./detail');
13
+
14
+ const ACTIONS = ['list', 'inspect', 'fields', 'build', 'create', 'copy', 'save', 'restore', 'settings', 'status', 'order', 'render', 'files'];
15
+ const TARGETED = ['inspect', 'copy', 'save', 'restore', 'settings', 'status', 'render'];
16
+ const HASHED = ['copy', 'save', 'restore', 'settings', 'status'];
17
+ const OPTIONS = {
18
+ list: ['--instance', '--detail-url'], inspect: ['--out'], fields: ['--out'], build: ['--config', '--scene', '--bindings', '--out'], create: ['--name', '--config', '--backup'],
19
+ copy: ['--name', '--expected-hash', '--backup'], save: ['--config', '--expected-hash', '--backup'],
20
+ restore: ['--config', '--expected-hash', '--backup'], settings: ['--config', '--expected-hash', '--backup', '--allow-permission-change'],
21
+ status: ['--status', '--expected-hash', '--backup'], order: ['--config', '--expected-hash', '--backup'],
22
+ render: ['--instance', '--detail-url', '--template-id'], files: ['--page', '--size'],
23
+ };
24
+
25
+ /** Parse exact command contracts before auth or remote access. */
26
+ function parseArgs(args) {
27
+ const positional = [];
28
+ const flags = {};
29
+ for (let i = 0; i < args.length; i++) {
30
+ const arg = args[i];
31
+ if (Object.hasOwn(flags, arg)) { m.fail('USAGE', arg); }
32
+ if (['--json', '--help', '-h', '--allow-permission-change'].includes(arg)) { flags[arg] = true; continue; }
33
+ if (Object.values(OPTIONS).flat().includes(arg)) {
34
+ if (!args[i + 1] || args[i + 1].startsWith('-')) { m.fail('USAGE', arg); }
35
+ flags[arg] = args[++i]; continue;
36
+ }
37
+ if (arg.startsWith('-')) { m.fail('USAGE', arg); }
38
+ positional.push(arg);
39
+ }
40
+ if (flags['--help'] || flags['-h']) { return { help: true }; }
41
+ if (flags['--detail-url']) {
42
+ if (!['list', 'render'].includes(positional[0]) || positional.length !== 1 || flags['--instance']) { m.fail('USAGE', '--detail-url: list/render only, without positional target'); }
43
+ const resolved = detail.parse(flags['--detail-url']);
44
+ positional.push(resolved.appType, resolved.formUuid);
45
+ if (positional[0] === 'render') { positional.push(flags['--template-id']); }
46
+ flags['--instance'] = resolved.instance;
47
+ } else if (flags['--template-id']) { m.fail('USAGE', '--template-id requires --detail-url'); }
48
+ const [action, appType, formUuid, templateId] = positional;
49
+ if (!ACTIONS.includes(action) || positional.length !== (TARGETED.includes(action) ? 4 : 3)
50
+ || !/^APP_[A-Za-z0-9_-]+$/.test(appType || '') || !/^[A-Za-z0-9_-]+$/.test(formUuid || '')
51
+ || TARGETED.includes(action) && !/^(0|[1-9]\d*)$/.test(templateId || '')
52
+ || Object.keys(flags).some(key => key !== '--json' && !OPTIONS[action].includes(key))) { m.fail('USAGE', t('print_template.usage')); }
53
+ if (['save', 'restore', 'settings', 'order'].includes(action) && !flags['--config']
54
+ || action === 'build' && (Boolean(flags['--config']) === Boolean(flags['--scene']) || flags['--bindings'] && !flags['--scene']
55
+ || flags['--scene'] && !scenes.names.includes(flags['--scene']))
56
+ || ['create', 'copy'].includes(action) && !flags['--name']?.trim()
57
+ || (HASHED.includes(action) || action === 'order') && !/^[a-f0-9]{64}$/.test(flags['--expected-hash'] || '')
58
+ || action === 'status' && !['START', 'STOP'].includes(flags['--status'])
59
+ || (action === 'render' || flags['--instance']) && !/^[A-Za-z0-9_-]+$/.test(flags['--instance'] || '')) { m.fail('USAGE', t('print_template.usage')); }
60
+ for (const [key, max] of [['--page', 100000], ['--size', 100]]) {
61
+ if (flags[key] !== undefined && (!/^[1-9]\d*$/.test(flags[key]) || Number(flags[key]) > max)) { m.fail('USAGE', key); }
62
+ }
63
+ if (['create', 'copy'].includes(action)) {
64
+ const name = flags['--name'].trim();
65
+ if ([...name].reduce((sum, ch) => sum + (ch.charCodeAt(0) > 127 ? 2 : 1), 0) > 40) { m.fail('INVALID_CONFIG', 'name: max 40 characters / 20 Chinese characters'); }
66
+ }
67
+ return { action, appType, formUuid, templateId, flags };
68
+ }
69
+
70
+ /** Validate a metadata-only patch, preserving translations and permission scope by default. */
71
+ function settings(base, raw, allowPermissions) {
72
+ const allowed = ['title', 'description', 'fileNameConfig', 'roleData'];
73
+ if (!m.object(raw) || !Object.keys(raw).length || Object.keys(raw).some(key => !allowed.includes(key))) { m.fail('INVALID_CONFIG', allowed.join(', ')); }
74
+ const result = {};
75
+ for (const key of ['title', 'description']) {
76
+ if (!Object.hasOwn(raw, key)) { continue; }
77
+ const value = typeof raw[key] === 'string' ? { zh_CN: raw[key], en_US: raw[key] } : raw[key];
78
+ if (!m.object(value) || Object.values(value).some(item => item !== null && typeof item !== 'string')
79
+ || key === 'title' && !value.zh_CN?.trim()) { m.fail('INVALID_CONFIG', key); }
80
+ result[key] = { ...(m.object(base[key]) ? base[key] : {}), ...value };
81
+ if (key === 'title' && [...result[key].zh_CN].reduce((sum, ch) => sum + (ch.charCodeAt(0) > 127 ? 2 : 1), 0) > 40) { m.fail('INVALID_CONFIG', 'title length'); }
82
+ }
83
+ if (Object.hasOwn(raw, 'description') && !Object.hasOwn(raw, 'title')) { result.title = base.title; }
84
+ // The native rename endpoint also replaces description; preserve its sibling.
85
+ if (Object.hasOwn(raw, 'title') && !Object.hasOwn(raw, 'description')) { result.description = base.description; }
86
+ if (Object.hasOwn(raw, 'fileNameConfig')) {
87
+ if (typeof raw.fileNameConfig !== 'string' || !raw.fileNameConfig.trim() || raw.fileNameConfig.length > 256
88
+ || /#|\$[^!]|\$!(?!\{(?:originator|instanceTitle|createTime|serialNo)\})/.test(raw.fileNameConfig)) {
89
+ m.fail('INVALID_CONFIG', 'fileNameConfig: use $!{originator}, $!{instanceTitle}, $!{createTime}, $!{serialNo}');
90
+ }
91
+ result.fileNameConfig = raw.fileNameConfig;
92
+ }
93
+ if (Object.hasOwn(raw, 'roleData')) {
94
+ if (!allowPermissions) { m.fail('PERMISSION_FLAG_REQUIRED', '--allow-permission-change'); }
95
+ if (!Array.isArray(raw.roleData) || !raw.roleData.length || raw.roleData.some(role => !m.object(role) || typeof role.roleType !== 'string' || typeof role.roleValue !== 'string'
96
+ || Object.keys(role).some(key => !['roleUuid', 'roleType', 'roleValue', 'isExclude'].includes(key)))) {
97
+ m.fail('INVALID_CONFIG', 'native roleData');
98
+ }
99
+ result.roleData = raw.roleData;
100
+ }
101
+ return result;
102
+ }
103
+
104
+ /** Execute native print operations with exclusive backups, stale-edit checks and no write replay. */
105
+ async function execute(parsed, client = createYidaClient(), preparedContent) {
106
+ const { action, appType, formUuid, templateId, flags } = parsed;
107
+ const detailTarget = flags['--detail-url'] ? detail.parse(flags['--detail-url']) : null;
108
+ if (detailTarget) { detail.verifyOrigin(detailTarget, client); }
109
+ const prefix = `/dingtalk/web/${appType}/query/printTemplate/`;
110
+ const target = { appType, formUuid, ...(templateId ? { templateId } : {}) };
111
+ const read = async (endpoint, params = {}) => m.unwrap(await client.get(prefix + endpoint + '.json', { formUuid, ...params }), endpoint);
112
+ const mutate = async (endpoint, params) => m.unwrap(await client.postFormOnce(prefix + endpoint + '.json', { formUuid, ...params }), endpoint);
113
+ const list = async (instance) => {
114
+ const raw = await read(instance ? 'listTemplateWithAuth' : 'listTemplate', instance ? { formInstId: instance } : {});
115
+ if (!Array.isArray(raw) || raw.some(item => !m.object(item) || !/^(0|[1-9]\d*)$/.test(String(item.templateId)) || item.formUuid !== formUuid)
116
+ || new Set(raw.map(item => String(item.templateId))).size !== raw.length) { m.fail('INVALID_RESPONSE', 'template list'); }
117
+ return raw.map(item => ({ ...item, templateId: String(item.templateId), title: m.json(item.title, 'title') || {}, description: m.json(item.description, 'description') || {} }));
118
+ };
119
+ const snapshot = async id => {
120
+ const template = (await list()).find(item => item.templateId === id);
121
+ if (!template) { m.fail('NOT_FOUND', id); }
122
+ if (!['', 'DEFAULT', 'CUSTOM', null, undefined].includes(template.templateType)) { m.fail('UNSUPPORTED', `templateType: ${template.templateType}`); }
123
+ const body = id === '0' ? {} : await read('queryTemplateById', { templateId: id });
124
+ const result = { appType, formUuid, templateId: id, template, content: m.content(body) };
125
+ return { ...result, configHash: m.hash(result) };
126
+ };
127
+ const getFields = async () => {
128
+ const [dictionary, codeCapability] = await Promise.all([
129
+ read('listDictionarys', { ccMode: 'tile' }), codes.capabilities(client),
130
+ ]);
131
+ return builder.fields(dictionary, codeCapability);
132
+ };
133
+ const output = { action, ...target };
134
+ if (action === 'list') {
135
+ const templates = await list(flags['--instance']);
136
+ return { ...output, templates, ...(flags['--instance'] ? { instance: flags['--instance'], authorizationScoped: true }
137
+ : { orderHash: m.hash({ template: templates, content: null }) }) };
138
+ }
139
+ if (action === 'fields') {
140
+ const result = { ...output, fields: await getFields() };
141
+ if (flags['--out']) { result.outputPath = m.write(result, flags['--out'], 'fields'); }
142
+ return result;
143
+ }
144
+ if (action === 'build') {
145
+ const source = flags['--scene'] ? scenes.document(flags['--scene'], flags['--bindings'] ? m.read(flags['--bindings']) : {}) : m.read(flags['--config']);
146
+ const config = builder.compile(source, await getFields());
147
+ const result = { ...output, config, saved: false, ...(flags['--scene'] ? { scene: flags['--scene'], document: source.document } : {}) };
148
+ if (flags['--out']) { result.outputPath = m.write(config, flags['--out'], 'built'); }
149
+ return result;
150
+ }
151
+ if (action === 'files') {
152
+ const result = m.unwrap(await client.get(`/dingtalk/web/${appType}/query/attach/list.json`, {
153
+ formUuid, currentPage: flags['--page'] || '1', pageSize: flags['--size'] || '50', source: 'PRINT',
154
+ }), 'attach/list');
155
+ if (!m.object(result) || !Array.isArray(result.data) || !Number.isFinite(result.totalCount)) { m.fail('INVALID_RESPONSE', 'print file list'); }
156
+ return { ...output, files: result };
157
+ }
158
+ // Validate content before creating anything. A later save failure must retain the created ID.
159
+ const initialContent = action === 'create' && flags['--config'] ? builder.compile(m.read(flags['--config']), await getFields()) : null;
160
+ let before;
161
+ if (action === 'render') {
162
+ const available = await list(flags['--instance']);
163
+ const template = available.find(item => item.templateId === templateId && item.status !== 'STOP');
164
+ if (!template) { m.fail('NOT_FOUND', 'template unavailable for this record'); }
165
+ if (!['', 'DEFAULT', 'CUSTOM', null, undefined].includes(template.templateType)) { m.fail('UNSUPPORTED', `templateType: ${template.templateType}`); }
166
+ before = { ...target, instance: flags['--instance'], template, authorizationScoped: true };
167
+ } else {
168
+ before = action === 'create' || action === 'order' ? { appType, formUuid, templates: await list() } : await snapshot(templateId);
169
+ }
170
+ if (action === 'inspect') {
171
+ if (flags['--out']) { before = { ...before, outputPath: m.write(before, flags['--out'], 'export') }; }
172
+ return { ...output, ...before };
173
+ }
174
+ const expectedHash = action === 'order' ? m.hash({ template: before.templates, content: null }) : before.configHash;
175
+ if ((HASHED.includes(action) || action === 'order') && flags['--expected-hash'] !== expectedHash) { m.fail('STALE_CONFIG', 'inspect again'); }
176
+ if (['save', 'restore'].includes(action) && (templateId === '0' || before.template.templateType === 'DEFAULT')) { m.fail('UNSUPPORTED', 'built-in default body'); }
177
+ let endpoint;
178
+ let params;
179
+ let expected;
180
+ if (action === 'save' || action === 'restore') {
181
+ let input = preparedContent || m.read(flags['--config']);
182
+ if (action === 'restore') {
183
+ if (input.appType !== appType || input.formUuid !== formUuid || String(input.templateId) !== templateId || !m.object(input.content)) { m.fail('TARGET_MISMATCH', 'backup target'); }
184
+ input = Object.fromEntries(['termContents', 'termJsonAttr', 'dictionaryList'].map(key => [key, input.content[key]]));
185
+ }
186
+ expected = builder.compile(input, await getFields());
187
+ endpoint = 'saveTemplate';
188
+ params = { templateId, termContents: expected.termContents, termJsonAttr: JSON.stringify(expected.termJsonAttr), dictionaryList: JSON.stringify(expected.dictionaryList) };
189
+ const modified = before.content.gmtModified;
190
+ if (modified !== null && modified !== undefined) {
191
+ const stamp = typeof modified === 'number' || /^\d+$/.test(String(modified)) ? Number(modified) : Date.parse(modified);
192
+ if (!Number.isFinite(stamp)) { m.fail('INVALID_RESPONSE', 'gmtModified'); }
193
+ params.gmtModified = String(stamp);
194
+ }
195
+ } else if (action === 'settings') {
196
+ expected = settings(before.template, m.read(flags['--config']), flags['--allow-permission-change']);
197
+ if (templateId === '0' && Object.keys(expected).some(key => key !== 'roleData')) { m.fail('UNSUPPORTED', 'default template settings: roleData only'); }
198
+ endpoint = 'updatePrintBaseInfo';
199
+ params = { templateId, ...Object.fromEntries(Object.entries(expected).map(([key, value]) => [key, typeof value === 'string' ? value : JSON.stringify(value)])) };
200
+ } else if (action === 'status') {
201
+ expected = flags['--status']; endpoint = 'savePrintTemplateStatus'; params = { templateId, status: expected };
202
+ } else if (action === 'order') {
203
+ expected = m.read(flags['--config']);
204
+ if (!Array.isArray(expected)) { m.fail('INVALID_CONFIG', 'template ID array'); }
205
+ expected = expected.map(String);
206
+ if (expected.length !== before.templates.length || new Set(expected).size !== expected.length || expected.some(id => !before.templates.some(item => item.templateId === id))) { m.fail('INVALID_CONFIG', 'include every template exactly once'); }
207
+ endpoint = 'savePrintTemplateOrder'; params = { templateIdList: JSON.stringify(expected.map(Number)) };
208
+ } else if (action === 'create' || action === 'copy') {
209
+ endpoint = action === 'create' ? 'createTemplate' : 'copyTemplateIndependently';
210
+ expected = flags['--name'].trim();
211
+ params = { title: JSON.stringify(buildYidaTitleI18n(expected)), description: JSON.stringify(buildYidaTitleI18n('')),
212
+ ...(action === 'copy' ? { originTemplateId: templateId } : {}) };
213
+ } else if (action === 'render') {
214
+ endpoint = 'print'; params = { ...(templateId === '0' ? {} : { templateId }), formInstId: flags['--instance'],
215
+ templateType: templateId === '0' ? 'DEFAULT' : 'CUSTOM', ...(detailTarget?.activityId ? { activityId: detailTarget.activityId } : {}) };
216
+ }
217
+ const backupPath = m.write({ ...before, action }, flags['--backup']);
218
+ const details = { target, backupPath, retrySafe: false, sideEffectState: 'unknown', readbackAllowed: true };
219
+ let accepted;
220
+ try { accepted = await mutate(endpoint, params); }
221
+ catch (error) { throw new CliError(error.message, { code: error.code || 'PRINT_TEMPLATE_WRITE_FAILED', details: { ...error.details, ...details } }); }
222
+ try {
223
+ if (action === 'render') {
224
+ if (!/^[1-9]\d*$/.test(String(accepted))) { m.fail('INVALID_RESPONSE', 'print file ID'); }
225
+ return { ...output, instance: flags['--instance'], authorizationScoped: true, backupPath, fileId: String(accepted), generationRequested: true, fileReadyVerified: false };
226
+ }
227
+ if (action === 'order') {
228
+ const after = await list();
229
+ if (!isDeepStrictEqual(after.map(item => item.templateId), expected)) { m.fail('READBACK_MISMATCH', action); }
230
+ return { ...output, backupPath, templates: after, readbackVerified: true };
231
+ }
232
+ let id = templateId;
233
+ if (action === 'create' || action === 'copy') {
234
+ id = String(accepted);
235
+ if (!/^[1-9]\d*$/.test(id)) { m.fail('INVALID_RESPONSE', 'new template ID'); }
236
+ details.createdTemplateId = id;
237
+ }
238
+ let after = await snapshot(id);
239
+ if (action === 'copy' && before.content.dictionaryList.length && !after.content.dictionaryList.length
240
+ && before.content.termContents === after.content.termContents
241
+ && isDeepStrictEqual(m.stable(before.content.termJsonAttr), m.stable(after.content.termJsonAttr))) {
242
+ // Native copy can omit the dictionary. Complete this specific known partial
243
+ // result once, preserving the new target's concurrency token and backup.
244
+ const copyBackupPath = m.write(after);
245
+ details.copyBackupPath = copyBackupPath;
246
+ details.createdTemplateId = id;
247
+ const native = builder.validateNative(before.content, await getFields());
248
+ const stamp = after.content.gmtModified;
249
+ await mutate('saveTemplate', { templateId: id, termContents: native.termContents,
250
+ termJsonAttr: JSON.stringify(native.termJsonAttr), dictionaryList: JSON.stringify(native.dictionaryList),
251
+ ...(stamp !== null && stamp !== undefined ? { gmtModified: String(stamp) } : {}) });
252
+ after = await snapshot(id);
253
+ }
254
+ if (['save', 'restore'].includes(action) && !m.matches(expected, after.content)) { m.fail('READBACK_MISMATCH', action); }
255
+ if (action === 'status' && after.template.status !== expected) { m.fail('READBACK_MISMATCH', action); }
256
+ if (['create', 'copy'].includes(action) && m.label(after.template.title) !== expected) { m.fail('READBACK_MISMATCH', 'title'); }
257
+ if (action === 'create' && initialContent) {
258
+ try {
259
+ const saved = await execute({ action: 'save', appType, formUuid, templateId: id,
260
+ flags: { '--config': flags['--config'], '--expected-hash': after.configHash } }, client, initialContent);
261
+ return { ...saved, action, created: true, contentSaved: true, createBackupPath: backupPath };
262
+ } catch (error) {
263
+ details.contentSaveDetails = error.details;
264
+ throw error;
265
+ }
266
+ }
267
+ if (action === 'copy' && !isDeepStrictEqual(m.stable(Object.fromEntries(['termContents', 'termJsonAttr', 'dictionaryList'].map(key => [key, before.content[key]]))),
268
+ m.stable(Object.fromEntries(['termContents', 'termJsonAttr', 'dictionaryList'].map(key => [key, after.content[key]]))))) { m.fail('READBACK_MISMATCH', 'copied content'); }
269
+ if (action === 'settings') {
270
+ for (const [key, value] of Object.entries(expected)) {
271
+ const saved = after.template[key];
272
+ if (key === 'roleData') {
273
+ const members = after.template.roleMembers;
274
+ if (!Array.isArray(members) || value.some(role => {
275
+ const member = members.find(item => item.roleType === role.roleType);
276
+ return !member || member.roleValue.map(item => item.key).sort().join(',') !== role.roleValue.split(',').sort().join(',')
277
+ || role.isExclude !== undefined && String(role.isExclude) !== String(member.isExclude ?? '');
278
+ }) || members.some(member => !value.some(role => role.roleType === member.roleType) && member.roleValue?.some(item => item.key && item.key !== 'n'))) { m.fail('READBACK_MISMATCH', key); }
279
+ continue;
280
+ }
281
+ if (['title', 'description'].includes(key)) {
282
+ if (Object.keys(value).some(locale => locale !== 'pureEn_US' && String(value[locale] ?? '') !== String(saved?.[locale] ?? ''))) { m.fail('READBACK_MISMATCH', key); }
283
+ } else if (!isDeepStrictEqual(m.stable(value), m.stable(saved))) { m.fail('READBACK_MISMATCH', key); }
284
+ }
285
+ }
286
+ return { ...output, ...after, backupPath, ...(details.copyBackupPath ? { copyBackupPath: details.copyBackupPath } : {}), readbackVerified: true };
287
+ } catch (error) {
288
+ throw new CliError(error.message, { code: error.code || 'PRINT_TEMPLATE_READBACK_FAILED',
289
+ details: { ...error.details, ...details, mutationAccepted: true, acceptedResult: accepted, sideEffectState: 'accepted_unverified' } });
290
+ }
291
+ }
292
+
293
+ /** CLI entry: JSON receipts share the same execution path as programmatic calls. */
294
+ async function run(args, options = {}) {
295
+ const parsed = parseArgs(args);
296
+ if (parsed.help) { process.stdout.write(t('print_template.usage') + '\n'); return; }
297
+ const result = await execute(parsed, options.client);
298
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
299
+ return result;
300
+ }
301
+
302
+ module.exports = { run, execute, parseArgs, settings };
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ const { object, fail, safe } = require('./model');
4
+ const names = ['order', 'quotation', 'delivery', 'receipt', 'approval', 'codes'];
5
+
6
+ /** Map scene roles to explicit live field references; compilation verifies IDs and table ownership. */
7
+ function document(scene, bindings = {}) {
8
+ if (!names.includes(scene)) { fail('INVALID_CONFIG', `scene: ${names.join('|')}`); }
9
+ safe(bindings);
10
+ if (!object(bindings) || Object.values(bindings).some(value => typeof value !== 'string' || !value.trim())) {
11
+ fail('INVALID_CONFIG', 'bindings: field role -> actual field name/key');
12
+ }
13
+ const result = JSON.parse(JSON.stringify(require(`../samples/yida-print-template/${scene}.json`)));
14
+ const used = new Set();
15
+ const ref = name => { used.add(name); return bindings[name] ?? name; };
16
+ for (const block of result.document.blocks) {
17
+ if (block.field) { block.field = ref(block.field); }
18
+ if (block.fields) { block.fields = block.fields.map(field => typeof field === 'string' ? ref(field) : { ...field, field: ref(field.field) }); }
19
+ if (block.columns && Array.isArray(block.columns)) {
20
+ block.columns = block.columns.map(field => typeof field === 'string' ? ref(field) : { ...field, field: ref(field.field) });
21
+ }
22
+ if (block.html) { block.html = block.html.replace(/\{\{([^{}]+)\}\}/g, (_, field) => `{{${ref(field.trim())}}}`); }
23
+ }
24
+ const unused = Object.keys(bindings).filter(key => !used.has(key));
25
+ if (unused.length) { fail('INVALID_CONFIG', `unknown scene roles: ${unused.join(', ')}`); }
26
+ return result;
27
+ }
28
+
29
+ module.exports = { names, document };
@@ -0,0 +1,14 @@
1
+ {
2
+ "document": {
3
+ "title": "申请审批单",
4
+ "style": "formal",
5
+ "blocks": [
6
+ { "type": "fields", "columns": 2, "fields": ["申请编号", "申请人", "申请日期", "申请金额"] },
7
+ { "type": "heading", "text": "申请事由" },
8
+ { "type": "field", "field": "申请说明" },
9
+ { "type": "heading", "text": "审批记录" },
10
+ { "type": "table", "field": "审批历史" },
11
+ { "type": "text", "text": "归档经办人:________________" }
12
+ ]
13
+ }
14
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "document": {
3
+ "title": "订单扫码核验单",
4
+ "blocks": [
5
+ { "type": "fields", "fields": ["订单编号"] },
6
+ { "type": "heading", "text": "订单二维码" },
7
+ { "type": "qrcode", "field": "订单编号", "width": 128, "height": 128 },
8
+ { "type": "heading", "text": "订单条形码" },
9
+ { "type": "barcode", "field": "订单编号", "width": 320, "height": 100 }
10
+ ]
11
+ }
12
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "document": {
3
+ "title": "发货交接单",
4
+ "style": "document",
5
+ "blocks": [
6
+ { "type": "fields", "columns": 2, "fields": ["发货编号", "收货单位", "发货日期", "经办人"] },
7
+ { "type": "heading", "text": "交接明细" },
8
+ { "type": "table", "field": "发货明细", "columns": ["商品名称", "发货数量"] },
9
+ { "type": "fields", "fields": ["交接备注"] },
10
+ { "type": "text", "text": "发货人:________________ 收货人:________________" },
11
+ { "type": "text", "text": "验收结果:________________ 签收日期:________________" }
12
+ ]
13
+ }
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "document": {
3
+ "title": "销售订单",
4
+ "style": "document",
5
+ "font": "宋体",
6
+ "blocks": [
7
+ { "type": "fields", "columns": 2, "fields": ["订单编号", "客户名称", "订单日期", "经办人"] },
8
+ { "type": "heading", "text": "商品明细" },
9
+ { "type": "table", "field": "订单明细", "columns": ["商品名称", "数量", "单价", "金额"] },
10
+ { "type": "fields", "fields": ["合计金额", "备注"] },
11
+ { "type": "text", "text": "客户签字:________________ 经办人签字:________________" }
12
+ ]
13
+ }
14
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "document": {
3
+ "title": "销售报价单",
4
+ "style": "formal",
5
+ "blocks": [
6
+ { "type": "fields", "columns": 2, "fields": ["报价编号", "客户名称", "报价日期", "经办人"] },
7
+ { "type": "heading", "text": "报价明细" },
8
+ { "type": "table", "field": "报价明细", "columns": ["商品名称", "数量", "单价", "金额"] },
9
+ { "type": "fields", "fields": ["报价总额", "报价说明"] },
10
+ { "type": "text", "text": "报价确认签字:________________ 日期:________________" }
11
+ ]
12
+ }
13
+ }