Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/export-filename-and-system-field-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@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}`),中文计划名不再变成一串下划线。

**`@objectstack/rest` — 导入接受翻译后的选项标签(导出↔导入闭环)**:导出与导入模板写出的是*翻译后*的选项标签(如 `待规划`),但导入强制转换只认作者原始 schema 的标签/值,导致用户把自己刚导出的本地化文件原样导回时 select 字段全部报 `invalid_option`。`prepareImportRequest` 新增 `localizeSchema` 钩子(REST 导入路由传入 `translateMetaItem`),把当前 locale 的翻译标签合并进字段选项作为匹配同义词——作者标签与选项 code 照常匹配,非法值照常报错,翻译失败时降级为仅作者标签匹配。新增导出 `mergeLocalizedOptionSynonyms(metaMap, localizedMetaMap)`。
34 changes: 30 additions & 4 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,30 @@ export const ShowcaseTranslationBundle = {
title: { label: 'Title' },
project: { label: 'Project' },
assignee: { label: 'Assignee' },
status: { label: 'Status' },
priority: { label: 'Priority' },
status: {
label: 'Status',
options: { backlog: 'Backlog', todo: 'To Do', in_progress: 'In Progress', in_review: 'In Review', done: 'Done' },
},
priority: {
label: 'Priority',
options: { low: 'Low', medium: 'Medium', high: 'High', urgent: 'Urgent' },
},
due_date: { label: 'Due Date' },
progress: { label: 'Progress' },
estimate_hours: { label: 'Estimate (h)' },
done: { label: 'Done' },
start_date: { label: 'Start Date' },
end_date: { label: 'End Date' },
created_at: { label: 'Created' },
location: { label: 'Work Location' },
cover: { label: 'Cover' },
labels: { label: 'Labels' },
notes: { label: 'Notes' },
sync_status: {
label: 'Sync Status',
options: { synced: 'Synced', failed: 'Failed' },
},
sync_error: { label: 'Sync Error' },
},
},
showcase_account: {
Expand Down Expand Up @@ -150,17 +163,30 @@ export const ShowcaseTranslationBundle = {
title: { label: '标题' },
project: { label: '项目' },
assignee: { label: '负责人' },
status: { label: '状态' },
priority: { label: '优先级' },
status: {
label: '状态',
options: { backlog: '待规划', todo: '待办', in_progress: '进行中', in_review: '评审中', done: '已完成' },
},
priority: {
label: '优先级',
options: { low: '低', medium: '中', high: '高', urgent: '紧急' },
},
due_date: { label: '截止日期' },
progress: { label: '进度' },
estimate_hours: { label: '预计工时' },
done: { label: '已完成' },
start_date: { label: '开始日期' },
end_date: { label: '结束日期' },
created_at: { label: '创建时间' },
location: { label: '工作地点' },
cover: { label: '封面' },
labels: { label: '标签' },
notes: { label: '备注' },
sync_status: {
label: '同步状态',
options: { synced: '已同步', failed: '同步失败' },
},
sync_error: { label: '同步错误' },
},
},
showcase_account: {
Expand Down
1 change: 1 addition & 0 deletions examples/app-showcase/src/ui/views/task.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion packages/plugins/plugin-reports/src/report-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}],
Expand Down
36 changes: 35 additions & 1 deletion packages/rest/src/export-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
36 changes: 36 additions & 0 deletions packages/rest/src/export-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,42 @@ export interface ExportFieldMeta {
hasDefault?: boolean;
}

/**
* Build the `Content-Disposition` header for an export download.
*
* The suggested filename is `<label>-<YYYYMMDD>-<HHMMSS>.<ext>` where the
* label is the object's (locale-translated) display label — so a browser
* saves e.g. `合同-20260714-153045.xlsx` instead of `contracts-2026-07-14.xlsx`.
* Non-ASCII labels ride the RFC 5987/6266 `filename*` parameter; the plain
* `filename` keeps an ASCII-safe fallback derived from the object API name
* for clients that don't understand `filename*`.
*/
export function exportContentDisposition(
objectName: string,
label: string | undefined,
ext: string,
now: Date = new Date(),
): string {
const pad = (n: number) => String(n).padStart(2, '0');
const stamp =
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const asciiBase = objectName.replace(/[^A-Za-z0-9_.-]/g, '_') || 'export';
// Keep unicode letters (CJK labels) but drop filesystem-hostile characters.
// eslint-disable-next-line no-control-regex
const utf8Base = String(label ?? '')
.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_')
.replace(/^[\s._-]+|[\s._-]+$/g, '')
.slice(0, 80) || asciiBase;
const asciiName = `${asciiBase}-${stamp}.${ext}`;
const utf8Name = `${utf8Base}-${stamp}.${ext}`;
// RFC 5987 pct-encoding: encodeURIComponent leaves `'()*` unescaped but
// they are not attr-chars, so escape them explicitly.
const encoded = encodeURIComponent(utf8Name)
.replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encoded}`;
}

/** Field types whose stored value points at another record. */
const REFERENCE_TYPES = new Set(['lookup', 'master_detail', 'user', 'reference', 'tree']);

Expand Down
101 changes: 101 additions & 0 deletions packages/rest/src/import-prepare.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Round-trip i18n for import coercion: the localized export and the import
* template surface *translated* option labels (e.g. 待规划 for `backlog`), so
* `prepareImportRequest` folds those labels into the field metaMap as matching
* synonyms — while the authored label and the option code keep working.
*/

import { describe, it, expect } from 'vitest';
import { prepareImportRequest, mergeLocalizedOptionSynonyms } from './import-prepare';
import { matchOption } from './import-coerce';
import { buildFieldMetaMap } from './export-format';

const SCHEMA = {
name: 'task',
fields: {
title: { name: 'title', type: 'text', label: 'Title' },
status: {
name: 'status', type: 'select', label: 'Status',
options: [
{ label: 'Backlog', value: 'backlog' },
{ label: 'Done', value: 'done' },
],
},
},
};

/** What `translateMetaItem` returns for SCHEMA under a zh-CN request. */
const localizeSchema = (schema: any) => ({
...schema,
fields: {
...schema.fields,
status: {
...schema.fields.status,
label: '状态',
options: [
{ label: '待规划', value: 'backlog' },
{ label: '已完成', value: 'done' },
],
},
},
});

const p = { getMetaItem: async () => ({ type: 'object', name: 'task', item: SCHEMA }) };

describe('mergeLocalizedOptionSynonyms', () => {
it('appends translated labels as extra options without touching authored ones', () => {
const metaMap = buildFieldMetaMap(SCHEMA);
mergeLocalizedOptionSynonyms(metaMap, buildFieldMetaMap(localizeSchema(SCHEMA)));
const options = metaMap.get('status')!.options!;
expect(matchOption('待规划', options)).toBe('backlog');
expect(matchOption('Backlog', options)).toBe('backlog');
expect(matchOption('backlog', options)).toBe('backlog');
expect(matchOption('已完成', options)).toBe('done');
});

it('is a no-op when the locale leaves labels unchanged (en request)', () => {
const metaMap = buildFieldMetaMap(SCHEMA);
const before = metaMap.get('status')!.options!.length;
mergeLocalizedOptionSynonyms(metaMap, buildFieldMetaMap(SCHEMA));
expect(metaMap.get('status')!.options!.length).toBe(before);
});
});

describe('prepareImportRequest — locale-translated option synonyms', () => {
it('folds the localizeSchema labels into the prepared metaMap', async () => {
const prep = await prepareImportRequest(
{ format: 'json', rows: [{ title: 'a', status: '待规划' }] },
{ p, objectName: 'task', maxRows: 10, localizeSchema },
);
expect(prep.ok).toBe(true);
if (!prep.ok) return;
const options = prep.prepared.metaMap.get('status')!.options!;
expect(matchOption('待规划', options)).toBe('backlog');
expect(matchOption('Backlog', options)).toBe('backlog');
});

it('without localizeSchema the authored-only matching is unchanged', async () => {
const prep = await prepareImportRequest(
{ format: 'json', rows: [{ title: 'a', status: 'Backlog' }] },
{ p, objectName: 'task', maxRows: 10 },
);
expect(prep.ok).toBe(true);
if (!prep.ok) return;
const options = prep.prepared.metaMap.get('status')!.options!;
expect(options).toHaveLength(2);
// The translated label is unknown to the authored options → no match.
expect(matchOption('待规划', options)).toBeUndefined();
});

it('a throwing localizeSchema degrades to authored-only matching', async () => {
const prep = await prepareImportRequest(
{ format: 'json', rows: [{ title: 'a', status: 'Backlog' }] },
{ p, objectName: 'task', maxRows: 10, localizeSchema: () => { throw new Error('no i18n'); } },
);
expect(prep.ok).toBe(true);
if (!prep.ok) return;
expect(matchOption('Backlog', prep.prepared.metaMap.get('status')!.options!)).toBe('backlog');
});
});
62 changes: 60 additions & 2 deletions packages/rest/src/import-prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,58 @@ export type PrepareImportResult =
* 50k async). Returns a discriminated result; the caller maps `!ok` to an HTTP
* error using the returned status/code/error.
*/
/**
* Fold a locale-translated schema's option labels into the authored metaMap as
* matching synonyms. The export route and the import-template both surface the
* *translated* option label (e.g. 待规划 for `backlog`), so a re-imported file
* carries those strings — but `matchOption` compares against the schema the
* registry serves, which only knows the authored label. Appending each
* translated label as an extra `{ label, value }` entry keeps the authored
* label and code working while also accepting what the localized UI displays.
*/
export function mergeLocalizedOptionSynonyms(
metaMap: Map<string, ExportFieldMeta>,
localized: Map<string, ExportFieldMeta>,
): void {
for (const [name, meta] of metaMap) {
const loc = localized.get(name);
if (!meta.options?.length || !loc?.options?.length) continue;
const known = new Set(
meta.options
.map((o) => (typeof o?.label === 'string' ? o.label.trim().toLowerCase() : ''))
.filter(Boolean),
);
const synonyms = loc.options.filter(
(o) =>
o
&& typeof o.label === 'string'
&& o.label.trim().length > 0
&& o.value !== undefined
&& !known.has(o.label.trim().toLowerCase()),
);
if (synonyms.length > 0) {
meta.options = [...meta.options, ...synonyms.map((o) => ({ label: o.label, value: o.value }))];
}
}
}

export async function prepareImportRequest(
body: any,
opts: { p: any; objectName: string; environmentId?: string; maxRows: number },
opts: {
p: any;
objectName: string;
environmentId?: string;
maxRows: number;
/**
* Optional hook applying the request locale to a schema document (the
* REST server passes `translateMetaItem` bound to the request). Used to
* accept locale-translated option labels — the strings the localized
* export / import template actually contain — as select-cell synonyms.
*/
localizeSchema?: (schema: any) => Promise<any> | any;
},
): Promise<PrepareImportResult> {
const { p, objectName, environmentId, maxRows } = opts;
const { p, objectName, environmentId, maxRows, localizeSchema } = opts;
const dryRun = body?.dryRun === true;

let writeMode: 'insert' | 'update' | 'upsert' =
Expand Down Expand Up @@ -325,6 +372,17 @@ export async function prepareImportRequest(
schema = await p.getObjectSchema(objectName, environmentId);
}
metaMap = buildFieldMetaMap(schema);
// Round-trip i18n: also accept the locale-translated option labels the
// localized export / import template display (merged as synonyms; the
// authored label and option code keep working).
if (schema && typeof localizeSchema === 'function') {
try {
const localized = await localizeSchema(schema);
if (localized && localized !== schema) {
mergeLocalizedOptionSynonyms(metaMap, buildFieldMetaMap(localized));
}
} catch { /* authored-only option matching */ }
}
} catch { /* pass-through coercion */ }

return {
Expand Down
Loading