Skip to content

Commit 607aaf4

Browse files
authored
feat(rest,spec): 导出文件名本地化 + 系统字段标签内置多语言回退 (#2911)
* feat(rest,spec): localized export filenames + built-in system-field label fallback - rest: GET /data/:object/export Content-Disposition now uses the object's display label + timestamp via RFC 5987/6266 (ASCII api-name fallback in filename=, localized label in filename*=UTF-8''). New exportContentDisposition(). - spec: translateObject gains built-in en/zh-CN/ja-JP/es-ES labels for the five registry-injected system fields (owner_id/created_at/created_by/ updated_at/updated_by), applied only when the label is still the injected English default; works without a translation bundle. REST meta translation no longer bails out when the bundle is missing (ETag is already locale-keyed). - plugin-reports: attachment filename sanitizer keeps Unicode letters/digits instead of flattening CJK to underscores. - showcase: enable exportOptions on the task In Progress view so export is demonstrated (and UI-testable) in the example app. * fix(rest): accept locale-translated option labels on import (export↔import round-trip) The localized export and the import template surface translated option labels (e.g. 待规划 for backlog), but import coercion only knew the authored schema labels — re-importing your own localized export failed every select field with invalid_option. - prepareImportRequest gains a localizeSchema hook; both REST import routes pass translateMetaItem so the request locale's labels merge into the field metaMap as matching synonyms (authored labels and option codes keep working; unknown values still fail; a throwing hook degrades to authored-only matching) - new mergeLocalizedOptionSynonyms export + import-prepare tests - app-showcase: complete the showcase_task en/zh-CN translation bundle (done/labels/sync_status/sync_error labels, status/priority/sync option maps) so templates and exports render fully localized
1 parent 2d4bd6b commit 607aaf4

12 files changed

Lines changed: 436 additions & 23 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/rest': patch
4+
'@objectstack/plugin-reports': patch
5+
---
6+
7+
导出文件名本地化 + 系统字段标签内置多语言回退。
8+
9+
**`@objectstack/rest` — 导出下载文件名**:`GET /data/:object/export``Content-Disposition` 不再是裸的 `<对象名>.<扩展名>`,改为「对象显示名-时间戳」:ASCII 兜底用 API 名(`filename="contracts-20260714-153045.xlsx"`),本地化标签(如中文)按 RFC 5987/6266 编码进 `filename*=UTF-8''…`(浏览器直接下载得到 `合同-20260714-153045.xlsx`)。新增导出 `exportContentDisposition(objectName, label, ext, now?)`
10+
11+
**`@objectstack/spec` — 系统字段标签回退**:ObjectQL 注册表给每个对象注入的系统字段(`owner_id`/`created_at`/`created_by`/`updated_at`/`updated_by`)只带英文标签,自定义对象又没有对应的翻译条目,导致中文界面的列表表头、导出文件、导入模板里漏出 "Owner"/"Created At" 等英文。`translateObject` 现内置这五个字段的 en/zh-CN/ja-JP/es-ES 标签表(措辞与平台生成的翻译包一致),仅当字段仍是注入的英文默认值时套用——作者自定义的标签绝不覆盖;无翻译包时也生效(`translateObject` 不再因缺 bundle 而提前返回,REST 元数据翻译路径同步放宽,缓存 ETag 本就按 locale 分键,无缓存串味风险)。
12+
13+
**`@objectstack/plugin-reports` — 附件文件名**:定时报表附件的文件名清洗从「非 ASCII 全部替换成 `_`」改为按 Unicode 字母/数字保留(`\p{L}\p{N}`),中文计划名不再变成一串下划线。
14+
15+
**`@objectstack/rest` — 导入接受翻译后的选项标签(导出↔导入闭环)**:导出与导入模板写出的是*翻译后*的选项标签(如 `待规划`),但导入强制转换只认作者原始 schema 的标签/值,导致用户把自己刚导出的本地化文件原样导回时 select 字段全部报 `invalid_option``prepareImportRequest` 新增 `localizeSchema` 钩子(REST 导入路由传入 `translateMetaItem`),把当前 locale 的翻译标签合并进字段选项作为匹配同义词——作者标签与选项 code 照常匹配,非法值照常报错,翻译失败时降级为仅作者标签匹配。新增导出 `mergeLocalizedOptionSynonyms(metaMap, localizedMetaMap)`

examples/app-showcase/src/system/translations/index.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,30 @@ export const ShowcaseTranslationBundle = {
3333
title: { label: 'Title' },
3434
project: { label: 'Project' },
3535
assignee: { label: 'Assignee' },
36-
status: { label: 'Status' },
37-
priority: { label: 'Priority' },
36+
status: {
37+
label: 'Status',
38+
options: { backlog: 'Backlog', todo: 'To Do', in_progress: 'In Progress', in_review: 'In Review', done: 'Done' },
39+
},
40+
priority: {
41+
label: 'Priority',
42+
options: { low: 'Low', medium: 'Medium', high: 'High', urgent: 'Urgent' },
43+
},
3844
due_date: { label: 'Due Date' },
3945
progress: { label: 'Progress' },
4046
estimate_hours: { label: 'Estimate (h)' },
47+
done: { label: 'Done' },
4148
start_date: { label: 'Start Date' },
4249
end_date: { label: 'End Date' },
4350
created_at: { label: 'Created' },
4451
location: { label: 'Work Location' },
4552
cover: { label: 'Cover' },
53+
labels: { label: 'Labels' },
4654
notes: { label: 'Notes' },
55+
sync_status: {
56+
label: 'Sync Status',
57+
options: { synced: 'Synced', failed: 'Failed' },
58+
},
59+
sync_error: { label: 'Sync Error' },
4760
},
4861
},
4962
showcase_account: {
@@ -150,17 +163,30 @@ export const ShowcaseTranslationBundle = {
150163
title: { label: '标题' },
151164
project: { label: '项目' },
152165
assignee: { label: '负责人' },
153-
status: { label: '状态' },
154-
priority: { label: '优先级' },
166+
status: {
167+
label: '状态',
168+
options: { backlog: '待规划', todo: '待办', in_progress: '进行中', in_review: '评审中', done: '已完成' },
169+
},
170+
priority: {
171+
label: '优先级',
172+
options: { low: '低', medium: '中', high: '高', urgent: '紧急' },
173+
},
155174
due_date: { label: '截止日期' },
156175
progress: { label: '进度' },
157176
estimate_hours: { label: '预计工时' },
177+
done: { label: '已完成' },
158178
start_date: { label: '开始日期' },
159179
end_date: { label: '结束日期' },
160180
created_at: { label: '创建时间' },
161181
location: { label: '工作地点' },
162182
cover: { label: '封面' },
183+
labels: { label: '标签' },
163184
notes: { label: '备注' },
185+
sync_status: {
186+
label: '同步状态',
187+
options: { synced: '已同步', failed: '同步失败' },
188+
},
189+
sync_error: { label: '同步错误' },
164190
},
165191
},
166192
showcase_account: {

examples/app-showcase/src/ui/views/task.view.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export const TaskViews = defineView({
6666
data,
6767
columns: [{ field: 'title' }, { field: 'project' }, { field: 'assignee' }, { field: 'status' }, { field: 'priority' }, { field: 'due_date' }],
6868
filter: [{ field: 'status', operator: 'equals', value: 'in_progress' }],
69+
exportOptions: ['csv', 'xlsx', 'json'],
6970
},
7071
urgent: {
7172
label: 'Urgent',

packages/plugins/plugin-reports/src/report-service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,9 @@ export class ReportService implements IReportService {
438438
subject,
439439
text: `Attached: ${result.rowCount} row(s).`,
440440
attachments: [{
441-
filename: `${(schedule.name ?? report.name).replace(/[^\w.-]+/g, '_')}-${ts.slice(0, 10)}.csv`,
441+
// Keep unicode letters (CJK schedule names) — only strip
442+
// filesystem-hostile characters, else 周报 becomes `__`.
443+
filename: `${(schedule.name ?? report.name).replace(/[^\p{L}\p{N}._-]+/gu, '_').replace(/^_+|_+$/g, '') || 'report'}-${ts.slice(0, 10)}.csv`,
442444
content: result.body,
443445
contentType: 'text/csv',
444446
}],

packages/rest/src/export-format.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,41 @@
88
*/
99

1010
import { describe, it, expect } from 'vitest';
11-
import { toArgb, cellFontColor, type ExportFieldMeta } from './export-format';
11+
import { toArgb, cellFontColor, exportContentDisposition, type ExportFieldMeta } from './export-format';
12+
13+
describe('exportContentDisposition', () => {
14+
const NOW = new Date(2026, 6, 14, 15, 30, 45); // 2026-07-14 15:30:45 local
15+
16+
it('uses the localized label in filename* and the API name as ASCII fallback', () => {
17+
expect(exportContentDisposition('contracts', '合同', 'xlsx', NOW)).toBe(
18+
`attachment; filename="contracts-20260714-153045.xlsx"; filename*=UTF-8''${encodeURIComponent('合同-20260714-153045.xlsx')}`,
19+
);
20+
});
21+
22+
it('falls back to the API name when no label is available', () => {
23+
expect(exportContentDisposition('contracts', undefined, 'csv', NOW)).toBe(
24+
`attachment; filename="contracts-20260714-153045.csv"; filename*=UTF-8''contracts-20260714-153045.csv`,
25+
);
26+
});
27+
28+
it('sanitizes hostile characters in both names', () => {
29+
const header = exportContentDisposition('a/b', '合 同: v2?', 'csv', NOW);
30+
expect(header).toContain('filename="a_b-20260714-153045.csv"');
31+
expect(header).toContain(`filename*=UTF-8''${encodeURIComponent('合 同_ v2-20260714-153045.csv')}`);
32+
});
33+
34+
it('percent-encodes RFC 5987 non-attr-chars that encodeURIComponent leaves alone', () => {
35+
const header = exportContentDisposition('obj', "a'b(c)", 'csv', NOW);
36+
expect(header).toContain("filename*=UTF-8''a%27b%28c%29-20260714-153045.csv");
37+
});
38+
39+
it('zero-pads date and time parts', () => {
40+
const early = new Date(2026, 0, 5, 9, 8, 7);
41+
expect(exportContentDisposition('obj', undefined, 'json', early)).toContain(
42+
'filename="obj-20260105-090807.json"',
43+
);
44+
});
45+
});
1246

1347
describe('toArgb', () => {
1448
it('expands 3-digit hex to opaque ARGB', () => {

packages/rest/src/export-format.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,42 @@ export interface ExportFieldMeta {
3737
hasDefault?: boolean;
3838
}
3939

40+
/**
41+
* Build the `Content-Disposition` header for an export download.
42+
*
43+
* The suggested filename is `<label>-<YYYYMMDD>-<HHMMSS>.<ext>` where the
44+
* label is the object's (locale-translated) display label — so a browser
45+
* saves e.g. `合同-20260714-153045.xlsx` instead of `contracts-2026-07-14.xlsx`.
46+
* Non-ASCII labels ride the RFC 5987/6266 `filename*` parameter; the plain
47+
* `filename` keeps an ASCII-safe fallback derived from the object API name
48+
* for clients that don't understand `filename*`.
49+
*/
50+
export function exportContentDisposition(
51+
objectName: string,
52+
label: string | undefined,
53+
ext: string,
54+
now: Date = new Date(),
55+
): string {
56+
const pad = (n: number) => String(n).padStart(2, '0');
57+
const stamp =
58+
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
59+
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
60+
const asciiBase = objectName.replace(/[^A-Za-z0-9_.-]/g, '_') || 'export';
61+
// Keep unicode letters (CJK labels) but drop filesystem-hostile characters.
62+
// eslint-disable-next-line no-control-regex
63+
const utf8Base = String(label ?? '')
64+
.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_')
65+
.replace(/^[\s._-]+|[\s._-]+$/g, '')
66+
.slice(0, 80) || asciiBase;
67+
const asciiName = `${asciiBase}-${stamp}.${ext}`;
68+
const utf8Name = `${utf8Base}-${stamp}.${ext}`;
69+
// RFC 5987 pct-encoding: encodeURIComponent leaves `'()*` unescaped but
70+
// they are not attr-chars, so escape them explicitly.
71+
const encoded = encodeURIComponent(utf8Name)
72+
.replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
73+
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encoded}`;
74+
}
75+
4076
/** Field types whose stored value points at another record. */
4177
const REFERENCE_TYPES = new Set(['lookup', 'master_detail', 'user', 'reference', 'tree']);
4278

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Round-trip i18n for import coercion: the localized export and the import
5+
* template surface *translated* option labels (e.g. 待规划 for `backlog`), so
6+
* `prepareImportRequest` folds those labels into the field metaMap as matching
7+
* synonyms — while the authored label and the option code keep working.
8+
*/
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { prepareImportRequest, mergeLocalizedOptionSynonyms } from './import-prepare';
12+
import { matchOption } from './import-coerce';
13+
import { buildFieldMetaMap } from './export-format';
14+
15+
const SCHEMA = {
16+
name: 'task',
17+
fields: {
18+
title: { name: 'title', type: 'text', label: 'Title' },
19+
status: {
20+
name: 'status', type: 'select', label: 'Status',
21+
options: [
22+
{ label: 'Backlog', value: 'backlog' },
23+
{ label: 'Done', value: 'done' },
24+
],
25+
},
26+
},
27+
};
28+
29+
/** What `translateMetaItem` returns for SCHEMA under a zh-CN request. */
30+
const localizeSchema = (schema: any) => ({
31+
...schema,
32+
fields: {
33+
...schema.fields,
34+
status: {
35+
...schema.fields.status,
36+
label: '状态',
37+
options: [
38+
{ label: '待规划', value: 'backlog' },
39+
{ label: '已完成', value: 'done' },
40+
],
41+
},
42+
},
43+
});
44+
45+
const p = { getMetaItem: async () => ({ type: 'object', name: 'task', item: SCHEMA }) };
46+
47+
describe('mergeLocalizedOptionSynonyms', () => {
48+
it('appends translated labels as extra options without touching authored ones', () => {
49+
const metaMap = buildFieldMetaMap(SCHEMA);
50+
mergeLocalizedOptionSynonyms(metaMap, buildFieldMetaMap(localizeSchema(SCHEMA)));
51+
const options = metaMap.get('status')!.options!;
52+
expect(matchOption('待规划', options)).toBe('backlog');
53+
expect(matchOption('Backlog', options)).toBe('backlog');
54+
expect(matchOption('backlog', options)).toBe('backlog');
55+
expect(matchOption('已完成', options)).toBe('done');
56+
});
57+
58+
it('is a no-op when the locale leaves labels unchanged (en request)', () => {
59+
const metaMap = buildFieldMetaMap(SCHEMA);
60+
const before = metaMap.get('status')!.options!.length;
61+
mergeLocalizedOptionSynonyms(metaMap, buildFieldMetaMap(SCHEMA));
62+
expect(metaMap.get('status')!.options!.length).toBe(before);
63+
});
64+
});
65+
66+
describe('prepareImportRequest — locale-translated option synonyms', () => {
67+
it('folds the localizeSchema labels into the prepared metaMap', async () => {
68+
const prep = await prepareImportRequest(
69+
{ format: 'json', rows: [{ title: 'a', status: '待规划' }] },
70+
{ p, objectName: 'task', maxRows: 10, localizeSchema },
71+
);
72+
expect(prep.ok).toBe(true);
73+
if (!prep.ok) return;
74+
const options = prep.prepared.metaMap.get('status')!.options!;
75+
expect(matchOption('待规划', options)).toBe('backlog');
76+
expect(matchOption('Backlog', options)).toBe('backlog');
77+
});
78+
79+
it('without localizeSchema the authored-only matching is unchanged', async () => {
80+
const prep = await prepareImportRequest(
81+
{ format: 'json', rows: [{ title: 'a', status: 'Backlog' }] },
82+
{ p, objectName: 'task', maxRows: 10 },
83+
);
84+
expect(prep.ok).toBe(true);
85+
if (!prep.ok) return;
86+
const options = prep.prepared.metaMap.get('status')!.options!;
87+
expect(options).toHaveLength(2);
88+
// The translated label is unknown to the authored options → no match.
89+
expect(matchOption('待规划', options)).toBeUndefined();
90+
});
91+
92+
it('a throwing localizeSchema degrades to authored-only matching', async () => {
93+
const prep = await prepareImportRequest(
94+
{ format: 'json', rows: [{ title: 'a', status: 'Backlog' }] },
95+
{ p, objectName: 'task', maxRows: 10, localizeSchema: () => { throw new Error('no i18n'); } },
96+
);
97+
expect(prep.ok).toBe(true);
98+
if (!prep.ok) return;
99+
expect(matchOption('Backlog', prep.prepared.metaMap.get('status')!.options!)).toBe('backlog');
100+
});
101+
});

packages/rest/src/import-prepare.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,11 +193,58 @@ export type PrepareImportResult =
193193
* 50k async). Returns a discriminated result; the caller maps `!ok` to an HTTP
194194
* error using the returned status/code/error.
195195
*/
196+
/**
197+
* Fold a locale-translated schema's option labels into the authored metaMap as
198+
* matching synonyms. The export route and the import-template both surface the
199+
* *translated* option label (e.g. 待规划 for `backlog`), so a re-imported file
200+
* carries those strings — but `matchOption` compares against the schema the
201+
* registry serves, which only knows the authored label. Appending each
202+
* translated label as an extra `{ label, value }` entry keeps the authored
203+
* label and code working while also accepting what the localized UI displays.
204+
*/
205+
export function mergeLocalizedOptionSynonyms(
206+
metaMap: Map<string, ExportFieldMeta>,
207+
localized: Map<string, ExportFieldMeta>,
208+
): void {
209+
for (const [name, meta] of metaMap) {
210+
const loc = localized.get(name);
211+
if (!meta.options?.length || !loc?.options?.length) continue;
212+
const known = new Set(
213+
meta.options
214+
.map((o) => (typeof o?.label === 'string' ? o.label.trim().toLowerCase() : ''))
215+
.filter(Boolean),
216+
);
217+
const synonyms = loc.options.filter(
218+
(o) =>
219+
o
220+
&& typeof o.label === 'string'
221+
&& o.label.trim().length > 0
222+
&& o.value !== undefined
223+
&& !known.has(o.label.trim().toLowerCase()),
224+
);
225+
if (synonyms.length > 0) {
226+
meta.options = [...meta.options, ...synonyms.map((o) => ({ label: o.label, value: o.value }))];
227+
}
228+
}
229+
}
230+
196231
export async function prepareImportRequest(
197232
body: any,
198-
opts: { p: any; objectName: string; environmentId?: string; maxRows: number },
233+
opts: {
234+
p: any;
235+
objectName: string;
236+
environmentId?: string;
237+
maxRows: number;
238+
/**
239+
* Optional hook applying the request locale to a schema document (the
240+
* REST server passes `translateMetaItem` bound to the request). Used to
241+
* accept locale-translated option labels — the strings the localized
242+
* export / import template actually contain — as select-cell synonyms.
243+
*/
244+
localizeSchema?: (schema: any) => Promise<any> | any;
245+
},
199246
): Promise<PrepareImportResult> {
200-
const { p, objectName, environmentId, maxRows } = opts;
247+
const { p, objectName, environmentId, maxRows, localizeSchema } = opts;
201248
const dryRun = body?.dryRun === true;
202249

203250
let writeMode: 'insert' | 'update' | 'upsert' =
@@ -325,6 +372,17 @@ export async function prepareImportRequest(
325372
schema = await p.getObjectSchema(objectName, environmentId);
326373
}
327374
metaMap = buildFieldMetaMap(schema);
375+
// Round-trip i18n: also accept the locale-translated option labels the
376+
// localized export / import template display (merged as synonyms; the
377+
// authored label and option code keep working).
378+
if (schema && typeof localizeSchema === 'function') {
379+
try {
380+
const localized = await localizeSchema(schema);
381+
if (localized && localized !== schema) {
382+
mergeLocalizedOptionSynonyms(metaMap, buildFieldMetaMap(localized));
383+
}
384+
} catch { /* authored-only option matching */ }
385+
}
328386
} catch { /* pass-through coercion */ }
329387

330388
return {

0 commit comments

Comments
 (0)