openyida 2026.10.20 → 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.
- package/README.md +4 -1
- package/bin/yida.js +9 -0
- package/lib/app/agent-app-type.js +12 -0
- package/lib/app/cool-card.js +5 -0
- package/lib/app/upgrade-app-theme.js +2 -1
- package/lib/core/command-manifest.js +10 -5
- package/lib/core/locales/en.js +12 -1
- package/lib/core/locales/zh.js +12 -1
- package/lib/core/sample.js +1 -0
- package/lib/core/task-center.js +3 -1
- package/lib/core/update.js +1 -1
- package/lib/feedback/feedback.js +1 -0
- package/lib/feedback/github.js +130 -0
- package/lib/print-template/builder.js +232 -0
- package/lib/print-template/codes.js +46 -0
- package/lib/print-template/contract.js +31 -0
- package/lib/print-template/detail.js +36 -0
- package/lib/print-template/model.js +97 -0
- package/lib/print-template/print-template.js +302 -0
- package/lib/print-template/scenes.js +29 -0
- package/lib/samples/yida-print-template/approval.json +14 -0
- package/lib/samples/yida-print-template/codes.json +12 -0
- package/lib/samples/yida-print-template/delivery.json +14 -0
- package/lib/samples/yida-print-template/order.json +14 -0
- package/lib/samples/yida-print-template/quotation.json +13 -0
- package/lib/samples/yida-print-template/receipt.json +13 -0
- package/lib/task-center/lookups.js +11 -4
- package/package.json +1 -1
- package/yida-skills/SKILL.md +3 -2
- package/yida-skills/skills/yida-cool-card/SKILL.md +3 -1
- package/yida-skills/skills/yida-feedback/SKILL.md +1 -1
- package/yida-skills/skills/yida-feedback/references/github-contribution.md +19 -2
- package/yida-skills/skills/yida-print-template/SKILL.md +134 -0
- package/yida-skills/skills/yida-print-template/references/detail.md +40 -0
- package/yida-skills/skills/yida-print-template/references/guide.md +35 -0
- package/yida-skills/skills/yida-print-template/references/scenarios.md +72 -0
- package/yida-skills/skills/yida-task-center/references/task-filters.md +2 -0
- package/yida-skills/skills-index.json +14 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { object, fail, json, safe, label, content } = require('./model');
|
|
4
|
+
const codes = require('./codes');
|
|
5
|
+
const TYPES = new Set(['value', 'date', 'img', 'table', 'commonCheckbox', 'yidaApproveNode']);
|
|
6
|
+
const FONTS = ['宋体', 'Arial', '新宋', '微软雅黑', '黑体'];
|
|
7
|
+
// The native PDF renderer recognizes these editor values, not localized font labels.
|
|
8
|
+
const FONT_VALUES = { 宋体: 'SimSun', Arial: 'Arial Unicode MS', 新宋: 'NSimSun', 微软雅黑: 'Microsoft YaHei', 黑体: 'SimHei' };
|
|
9
|
+
|
|
10
|
+
/** Escape literal values in XHTML and native dynamic-field attributes. */
|
|
11
|
+
function escape(value) {
|
|
12
|
+
return String(value ?? '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Fail on unrecognized configuration instead of silently dropping requested features. */
|
|
16
|
+
function keys(value, allowed, name) {
|
|
17
|
+
if (!object(value) || Object.keys(value).some(key => !allowed.includes(key))) { fail('INVALID_CONFIG', name); }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Build printable field groups from the same dictionaries consumed by the native editor. */
|
|
21
|
+
function fields(raw, codeCapability = { enabled: false, reason: 'capability_unverified' }) {
|
|
22
|
+
if (!object(raw) || !Array.isArray(raw.formField) && !Array.isArray(raw.forms) || !Array.isArray(raw.systemField)) {
|
|
23
|
+
fail('INVALID_RESPONSE', 'print dictionary');
|
|
24
|
+
}
|
|
25
|
+
const normalize = (items, group, parent) => (items || []).map(item => ({ ...item, label: label(item.description), group,
|
|
26
|
+
...(codes.eligible(item) ? { codeFormats: codeCapability.enabled ? [...codes.FORMATS] : [],
|
|
27
|
+
codeUnavailableReason: codeCapability.enabled ? null : codeCapability.reason } : {}),
|
|
28
|
+
parentKey: parent || null, children: normalize(item.children, group, item.key) }));
|
|
29
|
+
return [...normalize(raw.formField, 'form'), ...normalize(raw.systemField, 'system'),
|
|
30
|
+
...normalize(raw.remarkRecord ? [raw.remarkRecord] : [], 'system'), ...normalize(raw.approvedNode, 'approval'),
|
|
31
|
+
...(raw.forms || []).flatMap(form => normalize(form.formField, 'form', form.id).map(item => ({ ...item, formKey: form.id })))];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Resolve exact IDs or unambiguous names, scoped to a parent table when supplied. */
|
|
35
|
+
function resolve(items, ref) {
|
|
36
|
+
if (typeof ref !== 'string' || !ref) { fail('INVALID_CONFIG', 'field reference'); }
|
|
37
|
+
const byId = items.filter(item => item.key === ref);
|
|
38
|
+
const matches = byId.length ? byId : items.filter(item => item.label === ref);
|
|
39
|
+
if (matches.length !== 1) { fail('FIELD_REFERENCE', ref, { candidates: matches.map(item => ({ key: item.key, label: item.label })) }); }
|
|
40
|
+
return matches[0];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Reject executable HTML/Velocity input; native macros remain data references only. */
|
|
44
|
+
function html(value) {
|
|
45
|
+
if (typeof value !== 'string' || /<\s*\/?\s*(?:script|iframe|object|embed|form|link|meta|style)\b|\bon\w+\s*=|javascript\s*:|data\s*:|#\s*(?:set|evaluate|parse|include|foreach|if|macro)\b|\$!?\{/i.test(value)) {
|
|
46
|
+
fail('INVALID_CONFIG', 'static XHTML');
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Validate a complete native body while preserving advanced native attributes verbatim. */
|
|
52
|
+
function validateNative(raw, dictionary) {
|
|
53
|
+
const result = content(raw);
|
|
54
|
+
safe(result);
|
|
55
|
+
const attrs = result.termJsonAttr;
|
|
56
|
+
const entries = result.dictionaryList;
|
|
57
|
+
const ids = attrs.map(item => item?.id);
|
|
58
|
+
if (ids.some(id => typeof id !== 'string' || !/^[\w-]+$/.test(id)) || new Set(ids).size !== ids.length
|
|
59
|
+
|| entries.length !== attrs.length || new Set(entries.map(item => item.id)).size !== entries.length) {
|
|
60
|
+
fail('INVALID_CONFIG', 'native component IDs');
|
|
61
|
+
}
|
|
62
|
+
const all = [];
|
|
63
|
+
const visit = items => items.forEach(item => { all.push(item); visit(item.children || []); });
|
|
64
|
+
visit(dictionary);
|
|
65
|
+
for (const attr of attrs) {
|
|
66
|
+
const entry = entries.find(item => item.id === attr.id);
|
|
67
|
+
const code = entry && codes.resolveNative(entry.key, all);
|
|
68
|
+
const labelKey = typeof attr.content === 'string' && /exttype="label"/.test(attr.content)
|
|
69
|
+
? /fieldkey="([^"]+)"/.exec(attr.content)?.[1] : null;
|
|
70
|
+
if (!TYPES.has(attr.type) || !entry || entry.type !== attr.type || (code ? attr.type !== 'img' : !all.some(field => field.key === (labelKey || entry.key)))
|
|
71
|
+
|| typeof attr.content !== 'string' || !result.termContents.includes(` #macro_${attr.id} `)) {
|
|
72
|
+
fail('INVALID_CONFIG', `native component: ${attr.id}`);
|
|
73
|
+
}
|
|
74
|
+
// Native placeholders use ${label} in disabled input values. They are never evaluated locally.
|
|
75
|
+
html(attr.content.replace(/\$!?\{[^}]*\}/g, 'FIELD'));
|
|
76
|
+
// Repeating tables keep child image keys inside the parent macro, outside dictionaryList.
|
|
77
|
+
for (const [input] of attr.content.matchAll(/<input\b[^>]*>/gi)) {
|
|
78
|
+
const key = /\bkey\s*=\s*(["'])(.*?)\1/i.exec(input)?.[2];
|
|
79
|
+
if (codes.resolveNative(key, all) && !/\bkeytype\s*=\s*(["'])img\1/i.test(input)) {
|
|
80
|
+
fail('INVALID_CONFIG', 'native code image');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const macros = [...result.termContents.matchAll(/#macro_([\w-]+)/g)].map(match => match[1]);
|
|
85
|
+
if (macros.some(id => !ids.includes(id))) { fail('INVALID_CONFIG', 'unresolved macro'); }
|
|
86
|
+
html(result.termContents.replace(/ #macro_[\w-]+ /g, 'FIELD'));
|
|
87
|
+
return { termContents: result.termContents, termJsonAttr: attrs, dictionaryList: entries };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Compile named sections into the existing editor's native macros, never browser-only placeholders. */
|
|
91
|
+
function build(config, dictionary) {
|
|
92
|
+
keys(config, ['title', 'style', 'font', 'fontSize', 'width', 'blocks'], 'document');
|
|
93
|
+
const theme = config.style || 'document';
|
|
94
|
+
if (!['document', 'compact', 'formal'].includes(theme) || config.font !== undefined && !FONTS.includes(config.font)
|
|
95
|
+
|| config.title !== undefined && typeof config.title !== 'string'
|
|
96
|
+
|| config.fontSize !== undefined && (!Number.isFinite(config.fontSize) || config.fontSize < 8 || config.fontSize > 72)
|
|
97
|
+
|| config.width !== undefined && (!Number.isFinite(config.width) || config.width < 160 || config.width > 2000)
|
|
98
|
+
|| !Array.isArray(config.blocks) || !config.blocks.length) { fail('INVALID_CONFIG', 'document style / blocks'); }
|
|
99
|
+
const padding = theme === 'compact' ? '3px 5px' : '7px 9px';
|
|
100
|
+
const border = theme === 'formal' ? '#333333' : '#777777';
|
|
101
|
+
const cellStyle = `border:1px solid ${border};padding:${padding};vertical-align:top;`;
|
|
102
|
+
const tableStyle = `border-collapse:collapse;width:100%;border:1px solid ${border};`;
|
|
103
|
+
const termJsonAttr = [];
|
|
104
|
+
const dictionaryList = [];
|
|
105
|
+
let counter = 0;
|
|
106
|
+
const next = type => `${type}_${++counter}`;
|
|
107
|
+
const scalar = (field, options = {}, nested = false) => {
|
|
108
|
+
if (options.render !== undefined) { field = codes.transform(field, options.render); }
|
|
109
|
+
if (field.type === 'commonCheckbox') {
|
|
110
|
+
if (nested || !Array.isArray(field.children) || !field.children.length) { fail('UNSUPPORTED', `checkbox: ${field.label}`); }
|
|
111
|
+
const id = next('commonCheckbox');
|
|
112
|
+
const body = `<span id="${id}" key="${escape(field.key)}" contenteditable="false" class="render-commonCheckbox">${field.children.map(child => `<label key="${escape(child.key)}" keytype="${escape(child.type)}" keydescription="${escape(child.label)}">☐\${${escape(child.label)}}</label>`).join(' ')}</span>`;
|
|
113
|
+
termJsonAttr.push({ id, type: 'commonCheckbox', content: body });
|
|
114
|
+
dictionaryList.push({ id, type: 'commonCheckbox', key: field.key, description: field.label });
|
|
115
|
+
return ` #macro_${id} `;
|
|
116
|
+
}
|
|
117
|
+
if (!['value', 'date', 'img'].includes(field.type)) { fail('UNSUPPORTED', `${field.label}: ${field.type}`); }
|
|
118
|
+
const id = next(field.type);
|
|
119
|
+
let extra = '';
|
|
120
|
+
const width = options.width ?? (field.codeFormat === 'qrcode' ? 128 : field.codeFormat === 'barcode' ? 240 : undefined);
|
|
121
|
+
const height = options.height ?? (field.codeFormat === 'qrcode' ? 128 : field.codeFormat === 'barcode' ? 80 : undefined);
|
|
122
|
+
if (field.type === 'date') {
|
|
123
|
+
const format = options.format || 'yyyy-MM-dd';
|
|
124
|
+
if (!(field.children || []).some(child => child.key === format)) { fail('INVALID_CONFIG', `date format: ${format}`); }
|
|
125
|
+
extra += ` dateformat="${escape(format)}"`;
|
|
126
|
+
} else if (options.format !== undefined) { fail('INVALID_CONFIG', `format: ${field.label}`); }
|
|
127
|
+
if (width !== undefined || height !== undefined) {
|
|
128
|
+
if (field.type !== 'img' || [width, height].filter(v => v !== undefined).some(v => !Number.isFinite(v) || v <= 0 || v > 2000)) {
|
|
129
|
+
fail('INVALID_CONFIG', 'image dimensions');
|
|
130
|
+
}
|
|
131
|
+
extra += width ? ` width="${width}" td-width="${Number.isInteger(width) ? `${width}.0` : width}"` : '';
|
|
132
|
+
extra += height ? ` height="${height}" td-height="${Number.isInteger(height) ? `${height}.0` : height}"` : '';
|
|
133
|
+
}
|
|
134
|
+
const input = `<input id="${id}" key="${escape(field.key)}" keytype="${field.type}" type="text" disabled="disabled" class="render-${field.type}" style="border:0;background-color:transparent;color:inherit;font:inherit;" title="${escape(field.label)}(${escape(field.key)})" value="\${${escape(field.label)}}"${extra}/>`;
|
|
135
|
+
if (nested) { return input; }
|
|
136
|
+
termJsonAttr.push({ id, type: field.type, content: input });
|
|
137
|
+
dictionaryList.push({ id, type: field.type, key: field.key, description: field.label });
|
|
138
|
+
return ` #macro_${id} `;
|
|
139
|
+
};
|
|
140
|
+
const dynamic = (ref, options = {}) => {
|
|
141
|
+
const field = resolve(dictionary, ref);
|
|
142
|
+
if (field.type === 'table' || field.group === 'approval') {
|
|
143
|
+
if (['format', 'width', 'height', 'render'].some(key => options[key] !== undefined)) { fail('INVALID_CONFIG', 'table options'); }
|
|
144
|
+
const id = next(field.group === 'approval' ? 'yidaApproveNode' : 'table');
|
|
145
|
+
const selected = options.columns || field.children.map(child => child.key);
|
|
146
|
+
if (!Array.isArray(selected) || !selected.length) { fail('INVALID_CONFIG', 'table columns'); }
|
|
147
|
+
const columns = selected.map(col => {
|
|
148
|
+
if (typeof col === 'string') { col = { field: col }; }
|
|
149
|
+
keys(col, ['field', 'label', 'format', 'width', 'height', 'render'], 'table column');
|
|
150
|
+
return { ...col, definition: resolve(field.children, col.field) };
|
|
151
|
+
});
|
|
152
|
+
const row = `<tr class="render-table row">${columns.map(col => `<td style="${cellStyle}">${scalar(col.definition, col, true)}</td>`).join('')}</tr>`;
|
|
153
|
+
const head = `<tr class="render-table head">${columns.map(col => `<td style="${cellStyle}">${escape(col.label ?? col.definition.label)}</td>`).join('')}</tr>`;
|
|
154
|
+
const type = field.group === 'approval' ? 'yidaApproveNode' : 'table';
|
|
155
|
+
const body = type === 'table'
|
|
156
|
+
? `<table id="${id}" key="${escape(field.key)}" class="render-table" style="${tableStyle}"><tbody>${head}${row}</tbody></table>`
|
|
157
|
+
: `<div id="${id}" key="${escape(field.key)}" class="render-yidaApproveNode"><div>${columns.map(col => `<p>${escape(col.label ?? col.definition.label)}:${scalar(col.definition, col, true)}</p>`).join('')}</div></div>`;
|
|
158
|
+
termJsonAttr.push({ id, type, content: body });
|
|
159
|
+
dictionaryList.push({ id, type, key: field.key, description: field.label });
|
|
160
|
+
return ` #macro_${id} `;
|
|
161
|
+
}
|
|
162
|
+
return scalar(field, options);
|
|
163
|
+
};
|
|
164
|
+
const blocks = config.blocks.map(block => {
|
|
165
|
+
if (!object(block)) { fail('INVALID_CONFIG', 'block'); }
|
|
166
|
+
if (block.type === 'heading' || block.type === 'text') {
|
|
167
|
+
keys(block, ['type', 'text', 'align'], block.type);
|
|
168
|
+
if (typeof block.text !== 'string' || block.align && !['left', 'center', 'right'].includes(block.align)) { fail('INVALID_CONFIG', block.type); }
|
|
169
|
+
const tag = block.type === 'heading' ? 'h2' : 'p';
|
|
170
|
+
return `<${tag} style="text-align:${block.align || 'left'};">${escape(block.text)}</${tag}>`;
|
|
171
|
+
}
|
|
172
|
+
if (block.type === 'fields') {
|
|
173
|
+
keys(block, ['type', 'fields', 'columns'], 'fields block');
|
|
174
|
+
const count = block.columns ?? 1;
|
|
175
|
+
if (![1, 2, 3, 4].includes(count) || !Array.isArray(block.fields) || !block.fields.length) { fail('INVALID_CONFIG', 'fields block'); }
|
|
176
|
+
const cells = block.fields.map(ref => {
|
|
177
|
+
const item = typeof ref === 'string' ? { field: ref } : ref;
|
|
178
|
+
keys(item, ['field', 'label', 'format', 'width', 'height', 'render'], 'field');
|
|
179
|
+
const field = resolve(dictionary, item.field);
|
|
180
|
+
if (field.type === 'table' || field.group === 'approval') { fail('INVALID_CONFIG', 'use table block'); }
|
|
181
|
+
return `<td style="${cellStyle}width:${100 / count}%;">${escape(item.label ?? field.label)}:${dynamic(item.field, item)}</td>`;
|
|
182
|
+
});
|
|
183
|
+
const rows = [];
|
|
184
|
+
for (let i = 0; i < cells.length; i += count) { rows.push(`<tr>${cells.slice(i, i + count).join('')}${`<td style="${cellStyle}"></td>`.repeat(Math.max(0, count - cells.slice(i, i + count).length))}</tr>`); }
|
|
185
|
+
return `<table style="${tableStyle}"><tbody>${rows.join('')}</tbody></table>`;
|
|
186
|
+
}
|
|
187
|
+
if (block.type === 'table' || block.type === 'field') {
|
|
188
|
+
keys(block, ['type', 'field', 'columns', 'format', 'width', 'height', 'render'], block.type);
|
|
189
|
+
const field = resolve(dictionary, block.field);
|
|
190
|
+
if (block.type === 'table' && field.type !== 'table' && field.group !== 'approval') { fail('INVALID_CONFIG', 'table field'); }
|
|
191
|
+
if (block.columns !== undefined && field.type !== 'table' && field.group !== 'approval') { fail('INVALID_CONFIG', 'columns require table'); }
|
|
192
|
+
return dynamic(block.field, block);
|
|
193
|
+
}
|
|
194
|
+
if (codes.FORMATS.includes(block.type)) {
|
|
195
|
+
keys(block, ['type', 'field', 'width', 'height'], block.type);
|
|
196
|
+
const width = block.width ?? (block.type === 'qrcode' ? 128 : 240);
|
|
197
|
+
const height = block.height ?? (block.type === 'qrcode' ? 128 : 80);
|
|
198
|
+
const macro = dynamic(block.field, { render: block.type, width, height });
|
|
199
|
+
return `<table style="border-collapse:collapse;width:${width}px;"><tbody><tr><td style="width:${width}px;height:${height}px;padding:0;border:0;">${macro}</td></tr></tbody></table>`;
|
|
200
|
+
}
|
|
201
|
+
if (block.type === 'html') {
|
|
202
|
+
keys(block, ['type', 'html'], 'html block');
|
|
203
|
+
// Static XHTML supports arbitrary arrangements; dynamic tokens must resolve to live fields.
|
|
204
|
+
const result = html(block.html).replace(/\{\{([^{}]+)\}\}/g, (_, ref) => dynamic(ref.trim()));
|
|
205
|
+
if (/\{\{|\}\}/.test(result)) { fail('INVALID_CONFIG', 'unclosed field token'); }
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
if (block.type === 'image') {
|
|
209
|
+
keys(block, ['type', 'url', 'width', 'height'], 'image block');
|
|
210
|
+
if (typeof block.url !== 'string' || !/^https:\/\//.test(block.url) || [block.width, block.height].filter(v => v !== undefined).some(v => !Number.isFinite(v) || v <= 0 || v > 2000)) { fail('INVALID_CONFIG', 'image'); }
|
|
211
|
+
return `<p><img src="${escape(block.url)}"${block.width ? ` width="${block.width}"` : ''}${block.height ? ` height="${block.height}"` : ''}/></p>`;
|
|
212
|
+
}
|
|
213
|
+
if (block.type === 'divider') { keys(block, ['type'], 'divider'); return '<hr/>'; }
|
|
214
|
+
fail('UNSUPPORTED', `block: ${block.type}`);
|
|
215
|
+
});
|
|
216
|
+
const title = config.title ? `<h1 style="text-align:center;">${escape(config.title)}</h1>` : '';
|
|
217
|
+
const termContents = `<div style="font-family:${FONT_VALUES[config.font || '宋体']};font-size:${config.fontSize || 14}px;line-height:1.6;${config.width ? `width:${config.width}px;` : ''}">${title}${blocks.join('<p></p>')}</div>`;
|
|
218
|
+
return validateNative({ termContents, termJsonAttr, dictionaryList }, dictionary);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Accept authored documents or complete native exports; partial native bodies are rejected. */
|
|
222
|
+
function compile(raw, dictionary) {
|
|
223
|
+
if (object(raw) && Object.hasOwn(raw, 'document')) {
|
|
224
|
+
keys(raw, ['document'], 'config');
|
|
225
|
+
return build(raw.document, dictionary);
|
|
226
|
+
}
|
|
227
|
+
keys(raw, ['termContents', 'termJsonAttr', 'dictionaryList'], 'native config');
|
|
228
|
+
if (['termContents', 'termJsonAttr', 'dictionaryList'].some(key => !Object.hasOwn(raw, key))) { fail('INVALID_CONFIG', 'complete native config required'); }
|
|
229
|
+
return validateNative({ ...raw, termJsonAttr: json(raw.termJsonAttr, 'termJsonAttr'), dictionaryList: json(raw.dictionaryList, 'dictionaryList') }, dictionary);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = { fields, resolve, build, compile, validateNative, escape };
|
|
@@ -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 };
|