From d727c0137632d94e71793d926d68c963d3c37251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Tue, 14 Jul 2026 06:48:02 -0700 Subject: [PATCH 1/2] 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. --- .../export-filename-and-system-field-i18n.md | 13 +++ .../app-showcase/src/ui/views/task.view.ts | 1 + .../plugin-reports/src/report-service.ts | 4 +- packages/rest/src/export-format.test.ts | 36 ++++++++- packages/rest/src/export-format.ts | 36 +++++++++ packages/rest/src/rest-server.ts | 24 ++++-- packages/rest/src/rest.test.ts | 4 +- .../spec/src/system/i18n-resolver.test.ts | 79 +++++++++++++++++++ packages/spec/src/system/i18n-resolver.ts | 50 +++++++++++- 9 files changed, 232 insertions(+), 15 deletions(-) create mode 100644 .changeset/export-filename-and-system-field-i18n.md diff --git a/.changeset/export-filename-and-system-field-i18n.md b/.changeset/export-filename-and-system-field-i18n.md new file mode 100644 index 0000000000..bbc19b808e --- /dev/null +++ b/.changeset/export-filename-and-system-field-i18n.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': patch +'@objectstack/rest': patch +'@objectstack/plugin-reports': patch +--- + +导出文件名本地化 + 系统字段标签内置多语言回退。 + +**`@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?)`。 + +**`@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 分键,无缓存串味风险)。 + +**`@objectstack/plugin-reports` — 附件文件名**:定时报表附件的文件名清洗从「非 ASCII 全部替换成 `_`」改为按 Unicode 字母/数字保留(`\p{L}\p{N}`),中文计划名不再变成一串下划线。 diff --git a/examples/app-showcase/src/ui/views/task.view.ts b/examples/app-showcase/src/ui/views/task.view.ts index 43b66171cc..f477616df2 100644 --- a/examples/app-showcase/src/ui/views/task.view.ts +++ b/examples/app-showcase/src/ui/views/task.view.ts @@ -66,6 +66,7 @@ export const TaskViews = defineView({ data, columns: [{ field: 'title' }, { field: 'project' }, { field: 'assignee' }, { field: 'status' }, { field: 'priority' }, { field: 'due_date' }], filter: [{ field: 'status', operator: 'equals', value: 'in_progress' }], + exportOptions: ['csv', 'xlsx', 'json'], }, urgent: { label: 'Urgent', diff --git a/packages/plugins/plugin-reports/src/report-service.ts b/packages/plugins/plugin-reports/src/report-service.ts index de9c9cc037..fb7ec6718d 100644 --- a/packages/plugins/plugin-reports/src/report-service.ts +++ b/packages/plugins/plugin-reports/src/report-service.ts @@ -438,7 +438,9 @@ export class ReportService implements IReportService { subject, text: `Attached: ${result.rowCount} row(s).`, attachments: [{ - filename: `${(schedule.name ?? report.name).replace(/[^\w.-]+/g, '_')}-${ts.slice(0, 10)}.csv`, + // Keep unicode letters (CJK schedule names) — only strip + // filesystem-hostile characters, else 周报 becomes `__`. + filename: `${(schedule.name ?? report.name).replace(/[^\p{L}\p{N}._-]+/gu, '_').replace(/^_+|_+$/g, '') || 'report'}-${ts.slice(0, 10)}.csv`, content: result.body, contentType: 'text/csv', }], diff --git a/packages/rest/src/export-format.test.ts b/packages/rest/src/export-format.test.ts index 0e3e7ad3d8..e454041602 100644 --- a/packages/rest/src/export-format.test.ts +++ b/packages/rest/src/export-format.test.ts @@ -8,7 +8,41 @@ */ import { describe, it, expect } from 'vitest'; -import { toArgb, cellFontColor, type ExportFieldMeta } from './export-format'; +import { toArgb, cellFontColor, exportContentDisposition, type ExportFieldMeta } from './export-format'; + +describe('exportContentDisposition', () => { + const NOW = new Date(2026, 6, 14, 15, 30, 45); // 2026-07-14 15:30:45 local + + it('uses the localized label in filename* and the API name as ASCII fallback', () => { + expect(exportContentDisposition('contracts', '合同', 'xlsx', NOW)).toBe( + `attachment; filename="contracts-20260714-153045.xlsx"; filename*=UTF-8''${encodeURIComponent('合同-20260714-153045.xlsx')}`, + ); + }); + + it('falls back to the API name when no label is available', () => { + expect(exportContentDisposition('contracts', undefined, 'csv', NOW)).toBe( + `attachment; filename="contracts-20260714-153045.csv"; filename*=UTF-8''contracts-20260714-153045.csv`, + ); + }); + + it('sanitizes hostile characters in both names', () => { + const header = exportContentDisposition('a/b', '合 同: v2?', 'csv', NOW); + expect(header).toContain('filename="a_b-20260714-153045.csv"'); + expect(header).toContain(`filename*=UTF-8''${encodeURIComponent('合 同_ v2-20260714-153045.csv')}`); + }); + + it('percent-encodes RFC 5987 non-attr-chars that encodeURIComponent leaves alone', () => { + const header = exportContentDisposition('obj', "a'b(c)", 'csv', NOW); + expect(header).toContain("filename*=UTF-8''a%27b%28c%29-20260714-153045.csv"); + }); + + it('zero-pads date and time parts', () => { + const early = new Date(2026, 0, 5, 9, 8, 7); + expect(exportContentDisposition('obj', undefined, 'json', early)).toContain( + 'filename="obj-20260105-090807.json"', + ); + }); +}); describe('toArgb', () => { it('expands 3-digit hex to opaque ARGB', () => { diff --git a/packages/rest/src/export-format.ts b/packages/rest/src/export-format.ts index dccdf4732e..e649bb16d2 100644 --- a/packages/rest/src/export-format.ts +++ b/packages/rest/src/export-format.ts @@ -37,6 +37,42 @@ export interface ExportFieldMeta { hasDefault?: boolean; } +/** + * Build the `Content-Disposition` header for an export download. + * + * The suggested filename is `