@svadmin/core 0.53.0 → 0.55.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/package.json +31 -1
- package/src/approval-contract.ts +131 -0
- package/src/dashboard-contract.ts +72 -0
- package/src/date-time.ts +55 -0
- package/src/enterprise-maturity.ts +54 -0
- package/src/export-format.ts +44 -0
- package/src/export-hooks.svelte.ts +169 -8
- package/src/filter-values.ts +42 -0
- package/src/gantt.ts +84 -0
- package/src/i18n.svelte.ts +306 -2
- package/src/import-contract.ts +94 -6
- package/src/import-hooks.svelte.ts +13 -5
- package/src/index.ts +38 -2
- package/src/pivot.ts +367 -0
- package/src/query-session.svelte.ts +6 -1
- package/src/resource-contract.ts +111 -2
- package/src/schema-form.ts +204 -0
- package/src/schema.ts +7 -0
- package/src/spreadsheet-clipboard.ts +45 -0
- package/src/spreadsheet-workbook.ts +64 -0
- package/src/spreadsheet-xlsx.ts +181 -0
- package/src/task-hooks.svelte.ts +115 -46
- package/src/types.ts +18 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { Type, type TSchema } from '@sinclair/typebox';
|
|
2
|
+
import { Value } from '@sinclair/typebox/value';
|
|
3
|
+
|
|
4
|
+
export type SchemaFormScalar = string | number | boolean | null;
|
|
5
|
+
|
|
6
|
+
/** 条件路径从表单根开始,只能引用无条件的标量字段。 */
|
|
7
|
+
export interface SchemaFormCondition {
|
|
8
|
+
path: readonly string[];
|
|
9
|
+
equals?: SchemaFormScalar;
|
|
10
|
+
notEquals?: SchemaFormScalar;
|
|
11
|
+
exists?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SchemaFormSchema {
|
|
15
|
+
type?: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
properties?: Record<string, SchemaFormSchema>;
|
|
19
|
+
items?: SchemaFormSchema;
|
|
20
|
+
enum?: SchemaFormScalar[];
|
|
21
|
+
default?: unknown;
|
|
22
|
+
required?: string[];
|
|
23
|
+
visibleWhen?: SchemaFormCondition;
|
|
24
|
+
minLength?: number;
|
|
25
|
+
maxLength?: number;
|
|
26
|
+
pattern?: string;
|
|
27
|
+
minimum?: number;
|
|
28
|
+
maximum?: number;
|
|
29
|
+
minItems?: number;
|
|
30
|
+
maxItems?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const keys = new Set([
|
|
34
|
+
'type', 'title', 'description', 'properties', 'items', 'enum', 'default', 'required',
|
|
35
|
+
'visibleWhen', 'minLength', 'maxLength', 'pattern', 'minimum', 'maximum', 'minItems', 'maxItems',
|
|
36
|
+
]);
|
|
37
|
+
const scalar = (value: unknown): value is SchemaFormScalar => value === null ||
|
|
38
|
+
typeof value === 'string' || typeof value === 'boolean' ||
|
|
39
|
+
(typeof value === 'number' && Number.isFinite(value));
|
|
40
|
+
const fail = (): never => { throw new TypeError('Invalid schema form configuration.'); };
|
|
41
|
+
|
|
42
|
+
/** Schema 来自可信宿主,但仍拒绝未知关键字、循环、错误约束和超限结构。 */
|
|
43
|
+
export function assertSchemaFormSchema(schema: SchemaFormSchema): void {
|
|
44
|
+
let visited = 0;
|
|
45
|
+
const active = new Set<object>();
|
|
46
|
+
function visit(node: SchemaFormSchema, depth: number): void {
|
|
47
|
+
if (!node || typeof node !== 'object' || Array.isArray(node) ||
|
|
48
|
+
++visited > 1000 || depth > 32 || active.has(node)) fail();
|
|
49
|
+
if (Object.keys(node).some(key => !keys.has(key))) fail();
|
|
50
|
+
active.add(node);
|
|
51
|
+
const kind = node.type ?? (node.properties ? 'object' : undefined);
|
|
52
|
+
if (kind !== undefined && !['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'].includes(kind)) fail();
|
|
53
|
+
if (node.properties !== undefined) {
|
|
54
|
+
if (kind !== 'object' || !node.properties || typeof node.properties !== 'object' || Array.isArray(node.properties)) fail();
|
|
55
|
+
for (const child of Object.values(node.properties)) visit(child, depth + 1);
|
|
56
|
+
}
|
|
57
|
+
if (node.items !== undefined) {
|
|
58
|
+
if (kind !== 'array') fail();
|
|
59
|
+
visit(node.items, depth + 1);
|
|
60
|
+
if (node.items.visibleWhen !== undefined) fail();
|
|
61
|
+
}
|
|
62
|
+
if (node.required !== undefined && (kind !== 'object' || !Array.isArray(node.required) ||
|
|
63
|
+
node.required.some(key => typeof key !== 'string' || !Object.hasOwn(node.properties ?? {}, key)))) fail();
|
|
64
|
+
if (node.enum !== undefined && (!Array.isArray(node.enum) || node.enum.length === 0 ||
|
|
65
|
+
node.enum.length > 1000 || !node.enum.every(scalar) || kind === 'object' || kind === 'array')) fail();
|
|
66
|
+
for (const key of ['minLength', 'maxLength', 'minItems', 'maxItems'] as const) {
|
|
67
|
+
const limit = node[key];
|
|
68
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0 ||
|
|
69
|
+
(key.endsWith('Length') ? kind !== 'string' : kind !== 'array'))) fail();
|
|
70
|
+
}
|
|
71
|
+
for (const key of ['minimum', 'maximum'] as const) {
|
|
72
|
+
if (node[key] !== undefined && (!Number.isFinite(node[key]) || (kind !== 'number' && kind !== 'integer'))) fail();
|
|
73
|
+
}
|
|
74
|
+
if (node.pattern !== undefined) {
|
|
75
|
+
if (kind !== 'string' || typeof node.pattern !== 'string' || node.pattern.length > 1000) fail();
|
|
76
|
+
try { new RegExp(node.pattern); } catch { fail(); }
|
|
77
|
+
}
|
|
78
|
+
for (const [min, max] of [[node.minLength, node.maxLength], [node.minItems, node.maxItems], [node.minimum, node.maximum]]) {
|
|
79
|
+
if (min !== undefined && max !== undefined && min > max) fail();
|
|
80
|
+
}
|
|
81
|
+
const condition = node.visibleWhen;
|
|
82
|
+
if (condition !== undefined) {
|
|
83
|
+
if (!condition || typeof condition !== 'object' || Array.isArray(condition) ||
|
|
84
|
+
!Array.isArray(condition.path) || condition.path.length === 0 || condition.path.length > 32 ||
|
|
85
|
+
condition.path.some(part => typeof part !== 'string') ||
|
|
86
|
+
Object.keys(condition).some(key => !['path', 'equals', 'notEquals', 'exists'].includes(key)) ||
|
|
87
|
+
!['equals', 'notEquals', 'exists'].some(key => Object.hasOwn(condition, key))) fail();
|
|
88
|
+
if ((Object.hasOwn(condition, 'equals') && !scalar(condition.equals)) ||
|
|
89
|
+
(Object.hasOwn(condition, 'notEquals') && !scalar(condition.notEquals)) ||
|
|
90
|
+
(Object.hasOwn(condition, 'exists') && typeof condition.exists !== 'boolean')) fail();
|
|
91
|
+
let controller = schema;
|
|
92
|
+
for (const part of condition.path) {
|
|
93
|
+
if (controller.visibleWhen || !controller.properties || !Object.hasOwn(controller.properties, part)) fail();
|
|
94
|
+
const next = controller.properties?.[part];
|
|
95
|
+
if (!next) return fail();
|
|
96
|
+
controller = next;
|
|
97
|
+
}
|
|
98
|
+
if (controller.visibleWhen || controller.properties || controller.type === 'object' || controller.type === 'array') fail();
|
|
99
|
+
}
|
|
100
|
+
active.delete(node);
|
|
101
|
+
}
|
|
102
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema) ||
|
|
103
|
+
(schema.type !== 'object' && !schema.properties) || schema.visibleWhen) fail();
|
|
104
|
+
visit(schema, 0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function readSchemaFormPath(value: unknown, path: readonly string[]): unknown {
|
|
108
|
+
return path.reduce<unknown>((current, key) => (
|
|
109
|
+
current !== null && typeof current === 'object' && Object.hasOwn(current, key)
|
|
110
|
+
? (current as Record<string, unknown>)[key] : undefined
|
|
111
|
+
), value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function matchesSchemaFormCondition(condition: SchemaFormCondition | undefined, value: unknown): boolean {
|
|
115
|
+
if (!condition) return true;
|
|
116
|
+
const current = readSchemaFormPath(value, condition.path);
|
|
117
|
+
if (condition.exists !== undefined && (current !== undefined) !== condition.exists) return false;
|
|
118
|
+
if (Object.hasOwn(condition, 'equals') && !Object.is(current, condition.equals)) return false;
|
|
119
|
+
if (Object.hasOwn(condition, 'notEquals') && Object.is(current, condition.notEquals)) return false;
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function isSchemaFormNodeVisible(schema: SchemaFormSchema, rootValue: unknown): boolean {
|
|
124
|
+
return matchesSchemaFormCondition(schema.visibleWhen, rootValue);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface SchemaFormValidationError {
|
|
128
|
+
/** JSON 数组路径,不混淆字段名中的点、斜杠和数组索引。 */
|
|
129
|
+
path: string;
|
|
130
|
+
code: 'invalid';
|
|
131
|
+
message: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 不编译代码、不转换类型;值约束复用 TypeBox 的解释型校验。 */
|
|
135
|
+
export function prepareSchemaFormValue(schema: SchemaFormSchema, value: unknown): {
|
|
136
|
+
data: Record<string, unknown>;
|
|
137
|
+
errors: SchemaFormValidationError[];
|
|
138
|
+
} {
|
|
139
|
+
assertSchemaFormSchema(schema);
|
|
140
|
+
const errors: SchemaFormValidationError[] = [];
|
|
141
|
+
let visited = 0;
|
|
142
|
+
const invalid = (path: string[]) => {
|
|
143
|
+
if (errors.length < 50) errors.push({ path: JSON.stringify(path), code: 'invalid', message: 'Invalid value.' });
|
|
144
|
+
};
|
|
145
|
+
function visit(node: SchemaFormSchema, current: unknown, path: string[], required: boolean): unknown {
|
|
146
|
+
if (++visited > 1000 || path.length > 32) throw new TypeError('Schema form data limit exceeded.');
|
|
147
|
+
if (current === undefined && !required) return undefined;
|
|
148
|
+
const kind = node.type ?? (node.properties ? 'object' : node.enum ? undefined : 'string');
|
|
149
|
+
let check: TSchema;
|
|
150
|
+
switch (kind) {
|
|
151
|
+
case 'object': check = Type.Object({}); break;
|
|
152
|
+
case 'array': check = Type.Array(Type.Unknown(), {
|
|
153
|
+
...(node.minItems === undefined ? {} : { minItems: node.minItems }),
|
|
154
|
+
...(node.maxItems === undefined ? {} : { maxItems: node.maxItems }),
|
|
155
|
+
}); break;
|
|
156
|
+
case 'number': check = Type.Number({
|
|
157
|
+
...(node.minimum === undefined ? {} : { minimum: node.minimum }),
|
|
158
|
+
...(node.maximum === undefined ? {} : { maximum: node.maximum }),
|
|
159
|
+
}); break;
|
|
160
|
+
case 'integer': check = Type.Integer({
|
|
161
|
+
minimum: Math.max(Number.MIN_SAFE_INTEGER, node.minimum ?? Number.MIN_SAFE_INTEGER),
|
|
162
|
+
maximum: Math.min(Number.MAX_SAFE_INTEGER, node.maximum ?? Number.MAX_SAFE_INTEGER),
|
|
163
|
+
}); break;
|
|
164
|
+
case 'boolean': check = Type.Boolean(); break;
|
|
165
|
+
case 'null': check = Type.Null(); break;
|
|
166
|
+
case 'string': check = Type.String({
|
|
167
|
+
...(node.minLength === undefined ? {} : { minLength: node.minLength }),
|
|
168
|
+
...(node.maxLength === undefined ? {} : { maxLength: node.maxLength }),
|
|
169
|
+
...(node.pattern === undefined ? {} : { pattern: node.pattern }),
|
|
170
|
+
}); break;
|
|
171
|
+
default: check = Type.Unknown();
|
|
172
|
+
}
|
|
173
|
+
if (node.enum) check = Type.Intersect([check, Type.Union(node.enum.map(item => item === null ? Type.Null() : Type.Literal(item)))]);
|
|
174
|
+
if (!Value.Check(check, current)) invalid(path);
|
|
175
|
+
if (kind === 'array' && Array.isArray(current)) {
|
|
176
|
+
if (current.length > 1000 - visited) throw new TypeError('Schema form data limit exceeded.');
|
|
177
|
+
return current.map((item, index) => visit(node.items ?? { type: 'string' }, item, [...path, String(index)], true));
|
|
178
|
+
}
|
|
179
|
+
if (kind === 'object' && current !== null && typeof current === 'object' && !Array.isArray(current)) {
|
|
180
|
+
const source = current as Record<string, unknown>;
|
|
181
|
+
const result: Record<string, unknown> = {};
|
|
182
|
+
for (const key of Object.keys(source)) {
|
|
183
|
+
if (!Object.hasOwn(node.properties ?? {}, key)) invalid([...path, key]);
|
|
184
|
+
}
|
|
185
|
+
for (const [key, child] of Object.entries(node.properties ?? {})) {
|
|
186
|
+
if (!isSchemaFormNodeVisible(child, value)) continue;
|
|
187
|
+
const own = Object.hasOwn(source, key);
|
|
188
|
+
const next = visit(child, own ? source[key] : undefined, [...path, key], node.required?.includes(key) ?? false);
|
|
189
|
+
if (own || next !== undefined) Object.defineProperty(result, key, { value: next, enumerable: true, writable: true, configurable: true });
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
return current;
|
|
194
|
+
}
|
|
195
|
+
const projected = visit(schema, value, [], true);
|
|
196
|
+
return {
|
|
197
|
+
data: projected !== null && typeof projected === 'object' && !Array.isArray(projected) ? projected as Record<string, unknown> : {},
|
|
198
|
+
errors,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function validateSchemaFormValue(schema: SchemaFormSchema, value: unknown): SchemaFormValidationError[] {
|
|
203
|
+
return prepareSchemaFormValue(schema, value).errors;
|
|
204
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
export { createExactSchemaValidator, checkExact } from './schema-validation';
|
|
2
|
+
export {
|
|
3
|
+
readSchemaFormPath, matchesSchemaFormCondition, isSchemaFormNodeVisible, validateSchemaFormValue,
|
|
4
|
+
assertSchemaFormSchema, prepareSchemaFormValue,
|
|
5
|
+
} from './schema-form';
|
|
6
|
+
export type {
|
|
7
|
+
SchemaFormScalar, SchemaFormCondition, SchemaFormSchema, SchemaFormValidationError,
|
|
8
|
+
} from './schema-form';
|
|
2
9
|
export { snapshotPlainData } from './plain-data';
|
|
3
10
|
export type { JsonValue } from './plain-data';
|
|
4
11
|
export { decodeAuditEntry, decodeAuditCreate, decodeAuditQuery, decodeAuditEntries } from './audit-contract';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import Papa from 'papaparse';
|
|
2
|
+
|
|
3
|
+
/** 复制计算值而非可执行公式;文本中和与字段转义分别处理。 */
|
|
4
|
+
export function serializeSpreadsheetClipboard(rows: (string | number)[][], format: 'tsv' | 'csv'): string | undefined {
|
|
5
|
+
const width = rows[0]?.length ?? 0;
|
|
6
|
+
if (!rows.length || rows.length > 1000 || !width || width > 100 || rows.length * width > 10_000) return;
|
|
7
|
+
const safe: string[][] = [];
|
|
8
|
+
for (const row of rows) {
|
|
9
|
+
if (row.length !== width) return;
|
|
10
|
+
const values: string[] = [];
|
|
11
|
+
for (const value of row) {
|
|
12
|
+
if (typeof value !== 'string' && typeof value !== 'number') return;
|
|
13
|
+
if (typeof value === 'number' && !Number.isFinite(value)) return;
|
|
14
|
+
const text = typeof value === 'string' && (/^[\t\r\n]/.test(value) || /^\s*[=+@-]/.test(value))
|
|
15
|
+
? `'${value}` : String(value);
|
|
16
|
+
if (text.length > 10_000) return;
|
|
17
|
+
values.push(text);
|
|
18
|
+
}
|
|
19
|
+
safe.push(values);
|
|
20
|
+
}
|
|
21
|
+
const result = Papa.unparse(safe, { delimiter: format === 'csv' ? ',' : '\t', newline: '\n', quotes: true });
|
|
22
|
+
return result.length <= 1_000_000 ? result : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 剪贴板采用显式分隔符,纯文本中的逗号不自动拆列。 */
|
|
26
|
+
export function parseSpreadsheetClipboard(text: string, format: 'tsv' | 'csv' = 'tsv'): string[][] | undefined {
|
|
27
|
+
if (typeof text !== 'string' || text.length === 0 || text.length > 1_000_000
|
|
28
|
+
|| (format !== 'tsv' && format !== 'csv')) return undefined;
|
|
29
|
+
const parsed = Papa.parse<string[]>(text, {
|
|
30
|
+
delimiter: format === 'csv' ? ',' : '\t',
|
|
31
|
+
dynamicTyping: false,
|
|
32
|
+
skipEmptyLines: false,
|
|
33
|
+
preview: 1002,
|
|
34
|
+
});
|
|
35
|
+
if (parsed.errors.length || parsed.meta.truncated) return undefined;
|
|
36
|
+
const rows = parsed.data;
|
|
37
|
+
// 只去掉行终止符生成的末尾空记录,保留中间空行和空单元格。
|
|
38
|
+
if (/[\r\n]$/.test(text) && rows.at(-1)?.length === 1 && rows.at(-1)?.[0] === '') rows.pop();
|
|
39
|
+
const columns = rows[0]?.length ?? 0;
|
|
40
|
+
if (!rows.length || rows.length > 1000 || columns < 1 || columns > 100 || rows.length * columns > 10_000
|
|
41
|
+
|| rows.some(row => row.length !== columns || row.some(value => typeof value !== 'string' || value.length > 10_000))) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return rows;
|
|
45
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Type, type Static } from '@sinclair/typebox';
|
|
2
|
+
import { snapshotPlainData } from './plain-data';
|
|
3
|
+
import { checkExact } from './schema-validation';
|
|
4
|
+
|
|
5
|
+
export const SPREADSHEET_WORKBOOK_LIMITS = Object.freeze({
|
|
6
|
+
maxSheets: 32, maxRows: 1000, maxCols: 100, maxCells: 10_000,
|
|
7
|
+
maxCellLength: 10_000, maxTotalText: 1_000_000, maxJsonLength: 10_000_000,
|
|
8
|
+
});
|
|
9
|
+
const formatSchema = Type.Object({
|
|
10
|
+
numberFormat: Type.Optional(Type.Union([Type.Literal('general'), Type.Literal('number'), Type.Literal('percent')])),
|
|
11
|
+
decimalPlaces: Type.Optional(Type.Integer({ minimum: 0, maximum: 20 })),
|
|
12
|
+
bold: Type.Optional(Type.Boolean()), italic: Type.Optional(Type.Boolean()),
|
|
13
|
+
align: Type.Optional(Type.Union([Type.Literal('left'), Type.Literal('center'), Type.Literal('right')])),
|
|
14
|
+
}, { additionalProperties: false });
|
|
15
|
+
export type SpreadsheetCellFormat = Static<typeof formatSchema>;
|
|
16
|
+
const sheetSchema = Type.Object({
|
|
17
|
+
id: Type.String({ minLength: 1, maxLength: 200 }), name: Type.String({ minLength: 1, maxLength: 200 }),
|
|
18
|
+
rows: Type.Integer({ minimum: 1, maximum: 1000 }), cols: Type.Integer({ minimum: 1, maximum: 100 }),
|
|
19
|
+
cells: Type.Record(Type.String({ pattern: '^[A-Z]{1,2}[1-9][0-9]{0,3}$' }), Type.String({ maxLength: 10_000 })),
|
|
20
|
+
formats: Type.Optional(Type.Record(Type.String({ pattern: '^[A-Z]{1,2}[1-9][0-9]{0,3}$' }), formatSchema)),
|
|
21
|
+
}, { additionalProperties: false });
|
|
22
|
+
export type SpreadsheetSheet = Static<typeof sheetSchema>;
|
|
23
|
+
const workbookSchema = Type.Object({
|
|
24
|
+
protocolVersion: Type.Literal(1), sheets: Type.Array(sheetSchema, { minItems: 1, maxItems: 32 }),
|
|
25
|
+
activeSheetId: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
|
|
26
|
+
}, { additionalProperties: false });
|
|
27
|
+
export type SpreadsheetWorkbook = Static<typeof workbookSchema>;
|
|
28
|
+
function inside(key: string, sheet: SpreadsheetSheet): boolean {
|
|
29
|
+
const match = /^([A-Z]{1,2})([1-9][0-9]{0,3})$/.exec(key);
|
|
30
|
+
if (!match) return false;
|
|
31
|
+
let column = 0;
|
|
32
|
+
for (const char of match[1] ?? '') column = column * 26 + char.charCodeAt(0) - 64;
|
|
33
|
+
return column <= sheet.cols && Number(match[2]) <= sheet.rows;
|
|
34
|
+
}
|
|
35
|
+
export function snapshotSpreadsheetWorkbook(value: unknown): SpreadsheetWorkbook | undefined {
|
|
36
|
+
try {
|
|
37
|
+
const plain = snapshotPlainData(value);
|
|
38
|
+
if (!checkExact(workbookSchema, plain)) return;
|
|
39
|
+
const ids = new Set<string>();
|
|
40
|
+
let totalText = 0;
|
|
41
|
+
for (const sheet of plain.sheets) {
|
|
42
|
+
if (!sheet.id.trim() || !sheet.name.trim() || ids.has(sheet.id)
|
|
43
|
+
|| sheet.rows * sheet.cols > SPREADSHEET_WORKBOOK_LIMITS.maxCells) return;
|
|
44
|
+
ids.add(sheet.id);
|
|
45
|
+
for (const [key, text] of Object.entries(sheet.cells)) {
|
|
46
|
+
if (!inside(key, sheet)) return;
|
|
47
|
+
totalText += text.length;
|
|
48
|
+
if (totalText > SPREADSHEET_WORKBOOK_LIMITS.maxTotalText) return;
|
|
49
|
+
}
|
|
50
|
+
for (const key of Object.keys(sheet.formats ?? {})) if (!inside(key, sheet)) return;
|
|
51
|
+
}
|
|
52
|
+
if (plain.activeSheetId !== undefined && !ids.has(plain.activeSheetId)) return;
|
|
53
|
+
if (JSON.stringify(plain).length > SPREADSHEET_WORKBOOK_LIMITS.maxJsonLength) return;
|
|
54
|
+
return plain;
|
|
55
|
+
} catch { return; }
|
|
56
|
+
}
|
|
57
|
+
export function serializeSpreadsheetWorkbook(value: unknown): string | undefined {
|
|
58
|
+
const workbook = snapshotSpreadsheetWorkbook(value);
|
|
59
|
+
return workbook ? JSON.stringify(workbook) : undefined;
|
|
60
|
+
}
|
|
61
|
+
export function parseSpreadsheetWorkbook(value: string): SpreadsheetWorkbook | undefined {
|
|
62
|
+
if (typeof value !== 'string' || value.length > SPREADSHEET_WORKBOOK_LIMITS.maxJsonLength) return;
|
|
63
|
+
try { return snapshotSpreadsheetWorkbook(JSON.parse(value)); } catch { return; }
|
|
64
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { Cell, Workbook, Worksheet } from 'exceljs';
|
|
2
|
+
import { snapshotSpreadsheetWorkbook, type SpreadsheetWorkbook, type SpreadsheetCellFormat } from './spreadsheet-workbook';
|
|
3
|
+
|
|
4
|
+
export interface SpreadsheetXlsxOptions {
|
|
5
|
+
exceljs?: { Workbook: new () => Workbook };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SpreadsheetXlsxImportLimits {
|
|
9
|
+
maxBytes?: number;
|
|
10
|
+
maxSheets?: number;
|
|
11
|
+
maxRows?: number;
|
|
12
|
+
maxCols?: number;
|
|
13
|
+
maxCells?: number;
|
|
14
|
+
maxCellLength?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SpreadsheetXlsxImportOptions extends SpreadsheetXlsxImportLimits {
|
|
18
|
+
exceljs?: { Workbook: new () => Workbook };
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_IMPORT_LIMITS: Required<SpreadsheetXlsxImportLimits> = {
|
|
23
|
+
maxBytes: 25 * 1024 * 1024,
|
|
24
|
+
maxSheets: 32,
|
|
25
|
+
maxRows: 1000,
|
|
26
|
+
maxCols: 100,
|
|
27
|
+
maxCells: 10_000,
|
|
28
|
+
maxCellLength: 10_000,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function checkImport(signal: AbortSignal | undefined): void {
|
|
32
|
+
if (signal?.aborted) throw new Error('aborted');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cellText(cell: { value: unknown; formula?: string; result?: unknown }): string {
|
|
36
|
+
if (typeof cell.formula === 'string') return `=${cell.formula}`;
|
|
37
|
+
if (cell.value === null || cell.value === undefined) return '';
|
|
38
|
+
if (typeof cell.value === 'string') return cell.value;
|
|
39
|
+
if (typeof cell.value === 'number' || typeof cell.value === 'boolean') return String(cell.value);
|
|
40
|
+
if (typeof cell.value === 'object' && cell.value !== null && 'text' in cell.value
|
|
41
|
+
&& typeof cell.value.text === 'string') return cell.value.text;
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function cellFormat(cell: Cell): SpreadsheetCellFormat | undefined {
|
|
46
|
+
const format: SpreadsheetCellFormat = {};
|
|
47
|
+
if (cell.font?.bold !== undefined) format.bold = cell.font.bold;
|
|
48
|
+
if (cell.font?.italic !== undefined) format.italic = cell.font.italic;
|
|
49
|
+
if (cell.alignment?.horizontal === 'left' || cell.alignment?.horizontal === 'center'
|
|
50
|
+
|| cell.alignment?.horizontal === 'right') format.align = cell.alignment.horizontal;
|
|
51
|
+
if (cell.numFmt?.includes('%')) format.numberFormat = 'percent';
|
|
52
|
+
else if (cell.numFmt?.includes('0') || cell.numFmt?.includes('#')) format.numberFormat = 'number';
|
|
53
|
+
return Object.keys(format).length > 0 ? format : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function worksheetData(
|
|
57
|
+
worksheet: Worksheet,
|
|
58
|
+
limits: Required<SpreadsheetXlsxImportLimits>,
|
|
59
|
+
signal: AbortSignal | undefined,
|
|
60
|
+
): SpreadsheetWorkbook['sheets'][number] {
|
|
61
|
+
const rows = worksheet.rowCount;
|
|
62
|
+
const cols = worksheet.columnCount;
|
|
63
|
+
if (!Number.isSafeInteger(rows) || rows <= 0 || rows > limits.maxRows
|
|
64
|
+
|| !Number.isSafeInteger(cols) || cols <= 0 || cols > limits.maxCols
|
|
65
|
+
|| rows * cols > limits.maxCells) throw new Error('limit');
|
|
66
|
+
const cells: Record<string, string> = {};
|
|
67
|
+
const formats: Record<string, SpreadsheetCellFormat> = {};
|
|
68
|
+
for (let row = 1; row <= rows; row += 1) {
|
|
69
|
+
checkImport(signal);
|
|
70
|
+
for (let col = 1; col <= cols; col += 1) {
|
|
71
|
+
const cell = worksheet.getCell(row, col);
|
|
72
|
+
const text = cellText(cell);
|
|
73
|
+
if (text.length > limits.maxCellLength) throw new Error('limit');
|
|
74
|
+
const format = cellFormat(cell);
|
|
75
|
+
if (text !== '') {
|
|
76
|
+
cells[cell.address] = text;
|
|
77
|
+
}
|
|
78
|
+
if (format) formats[cell.address] = format;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
// XLSX 不保存 SVAdmin 的内部 sheet id;名称是唯一可复现的导入身份。
|
|
83
|
+
id: worksheet.name,
|
|
84
|
+
name: worksheet.name,
|
|
85
|
+
rows,
|
|
86
|
+
cols,
|
|
87
|
+
cells,
|
|
88
|
+
...(Object.keys(formats).length > 0 ? { formats } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 读取有界 XLSX;失败、超限或取消时原子返回 undefined。 */
|
|
93
|
+
export async function parseSpreadsheetXlsx(
|
|
94
|
+
bytes: unknown,
|
|
95
|
+
options: SpreadsheetXlsxImportOptions = {},
|
|
96
|
+
): Promise<SpreadsheetWorkbook | undefined> {
|
|
97
|
+
const limits = {
|
|
98
|
+
...DEFAULT_IMPORT_LIMITS,
|
|
99
|
+
...Object.fromEntries(Object.entries(options).filter(([key]) => key.startsWith('max'))),
|
|
100
|
+
} as Required<SpreadsheetXlsxImportLimits>;
|
|
101
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength > limits.maxBytes) return;
|
|
102
|
+
try {
|
|
103
|
+
checkImport(options.signal);
|
|
104
|
+
const ExcelJS = options.exceljs ?? (await import('exceljs')).default;
|
|
105
|
+
checkImport(options.signal);
|
|
106
|
+
const workbook = new ExcelJS.Workbook();
|
|
107
|
+
type XlsxInput = Parameters<typeof workbook.xlsx.load>[0];
|
|
108
|
+
const xlsxBytes = new Uint8Array(bytes).slice().buffer as ArrayBuffer;
|
|
109
|
+
await workbook.xlsx.load(xlsxBytes as XlsxInput);
|
|
110
|
+
checkImport(options.signal);
|
|
111
|
+
if (workbook.worksheets.length === 0 || workbook.worksheets.length > limits.maxSheets) return;
|
|
112
|
+
const sheets = workbook.worksheets.map((worksheet) => worksheetData(worksheet, limits, options.signal));
|
|
113
|
+
const activeIndex = workbook.views[0]?.activeTab ?? 0;
|
|
114
|
+
const activeSheetId = sheets[activeIndex]?.id ?? sheets[0]?.id;
|
|
115
|
+
if (!activeSheetId) return;
|
|
116
|
+
return snapshotSpreadsheetWorkbook({ protocolVersion: 1, activeSheetId, sheets });
|
|
117
|
+
} catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function numberFormat(format: string | undefined, decimalPlaces: number | undefined): string | undefined {
|
|
123
|
+
const places = decimalPlaces === undefined ? 2 : decimalPlaces;
|
|
124
|
+
const fraction = places ? `.${'0'.repeat(places)}` : '';
|
|
125
|
+
if (format === 'percent') return `0${fraction}%`;
|
|
126
|
+
if (format === 'number') return `#,##0${fraction}`;
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 公式按文本保留,避免将不可信内容升级为 Excel 可执行公式。 */
|
|
131
|
+
export async function serializeSpreadsheetXlsx(
|
|
132
|
+
value: unknown, options: SpreadsheetXlsxOptions = {},
|
|
133
|
+
): Promise<Uint8Array | undefined> {
|
|
134
|
+
const workbook = snapshotSpreadsheetWorkbook(value);
|
|
135
|
+
if (!workbook) return;
|
|
136
|
+
const names = new Set<string>();
|
|
137
|
+
for (const sheet of workbook.sheets) {
|
|
138
|
+
const name = sheet.name.toLowerCase();
|
|
139
|
+
if (sheet.name.length > 31 || /[\\/*?:[\]]/.test(sheet.name)
|
|
140
|
+
|| [...sheet.name].some(character => character.charCodeAt(0) < 32)
|
|
141
|
+
|| sheet.name.startsWith("'") || sheet.name.endsWith("'")
|
|
142
|
+
|| name === 'history' || names.has(name)) return;
|
|
143
|
+
names.add(name);
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const ExcelJS = options.exceljs ?? (await import('exceljs')).default;
|
|
147
|
+
const output = new ExcelJS.Workbook();
|
|
148
|
+
output.views = [{
|
|
149
|
+
x: 0, y: 0, width: 1200, height: 800, firstSheet: 0, visibility: 'visible',
|
|
150
|
+
activeTab: Math.max(0, workbook.sheets.findIndex(sheet => sheet.id === workbook.activeSheetId)),
|
|
151
|
+
}];
|
|
152
|
+
for (const sheet of workbook.sheets) {
|
|
153
|
+
const target = output.addWorksheet(sheet.name);
|
|
154
|
+
// 空白尾部也属于网格,避免导出后尺寸缩小到最后一个非空单元格。
|
|
155
|
+
target.getCell(sheet.rows, sheet.cols).value = '';
|
|
156
|
+
const addresses = new Set([...Object.keys(sheet.cells), ...Object.keys(sheet.formats ?? {})]);
|
|
157
|
+
for (const address of addresses) {
|
|
158
|
+
const raw = sheet.cells[address] ?? '';
|
|
159
|
+
const cell = target.getCell(address);
|
|
160
|
+
const format = sheet.formats?.[address];
|
|
161
|
+
const numeric = Number(raw);
|
|
162
|
+
const explicitNumber = format?.numberFormat === 'number' || format?.numberFormat === 'percent';
|
|
163
|
+
cell.value = explicitNumber && raw !== '' && Number.isFinite(numeric)
|
|
164
|
+
&& String(numeric) === raw && Math.abs(numeric) <= Number.MAX_SAFE_INTEGER ? numeric : raw;
|
|
165
|
+
if (format) {
|
|
166
|
+
cell.font = {
|
|
167
|
+
...(format.bold === undefined ? {} : { bold: format.bold }),
|
|
168
|
+
...(format.italic === undefined ? {} : { italic: format.italic }),
|
|
169
|
+
};
|
|
170
|
+
if (format.align !== undefined) cell.alignment = { horizontal: format.align };
|
|
171
|
+
const numFmt = numberFormat(format.numberFormat, format.decimalPlaces);
|
|
172
|
+
if (numFmt !== undefined) cell.numFmt = numFmt;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const result = await output.xlsx.writeBuffer();
|
|
177
|
+
return new Uint8Array(result);
|
|
178
|
+
} catch {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|