Skip to content

Commit d45a938

Browse files
committed
fix(rest): export boolean cells follow the caller's language
GET /data/:object/export wrote every boolean / toggle cell as the literal 是 / 否, while the header row of the same file followed the caller's locale. The route now resolves the request's i18n service once, translates the header with it, and writes the boolean cells in templateText(locale).booleanWords for the locale extractLocale reads off that same service: yes / no, or 是 / 否 for a zh* locale. csv, xlsx and json alike. The importer reads both languages back. Existing pins on 是 / 否 named no locale: the Chinese fixtures now name a zh-CN caller (their recorded bytes are unchanged); the English fixture expects yes / no. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent d913467 commit d45a938

8 files changed

Lines changed: 97 additions & 30 deletions

‎packages/rest/src/data-action-routes.ts‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ import {
6868
type ExportCellMeta,
6969
} from './export-format.js';
7070
import { importControlOptOutRefusal, prepareImportRequest } from './import-prepare.js';
71-
import { readTemplateMode, templateFieldDefs } from './import-template.js';
71+
import { readTemplateMode, templateFieldDefs, templateText } from './import-template.js';
7272
import { refuseRepeatedQueryParams } from './query-multiplicity.js';
7373
import { refuseUnknownQueryParams } from './query-allowlist.js';
7474
import { handleRouteError, logUnexpectedRouteError, mapDataError, nestedRefusalEnvelope } from './error-response.js';
@@ -200,6 +200,8 @@ export interface DataActionRouteHost {
200200
translateMetaItem: RestServer['translateMetaItem'];
201201
resolveMessageTranslator: RestServer['resolveMessageTranslator'];
202202
resolveSecurityService: RestServer['resolveSecurityService'];
203+
resolveI18nService: RestServer['resolveI18nService'];
204+
extractLocale: RestServer['extractLocale'];
203205
answerImportTemplate: RestServer['answerImportTemplate'];
204206
}
205207

@@ -884,8 +886,15 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
884886
//
885887
// Values are formatted for readability from the object schema: lookup /
886888
// user fields resolve to a name (via injected $expand), select fields to
887-
// their option label, booleans to 是/否, dates to YYYY-MM-DD. When the
888-
// schema is unavailable the raw stored values stream through unchanged.
889+
// their option label, booleans to the caller's yes / no words, dates to
890+
// YYYY-MM-DD. When the schema is unavailable the raw stored values stream
891+
// through unchanged.
892+
//
893+
// [#24945] Boolean / toggle cells are written in the language of the
894+
// header row: `templateText(locale).booleanWords` (yes / no, or 是 / 否 for
895+
// a `zh*` locale), for the locale the header labels are translated to
896+
// (`Accept-Language`, then `?locale=`, then the i18n service's default).
897+
// The importer reads both languages back, so the file re-imports.
889898
//
890899
// [#8373] `datetime` cells render in the caller's BUSINESS timezone
891900
// (`ExecutionContext.timezone`), so the file agrees with the screen;
@@ -1084,7 +1093,7 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
10841093
const fieldsExplicit = !!fields && fields.length > 0;
10851094

10861095
// Field metadata drives readable formatting (lookup names, select
1087-
// labels, 是/否, formatted dates) and the $expand that resolves
1096+
// labels, yes / no words, formatted dates) and the $expand that resolves
10881097
// references. Best-effort: when the schema is unavailable the export
10891098
// falls back to raw values, byte-identical to the un-formatted path.
10901099
let metaMap = new Map<string, ExportCellMeta>();
@@ -1111,13 +1120,23 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
11111120
}
11121121
return schema;
11131122
};
1123+
// [#24945] The file's language, read once for both of its halves:
1124+
// the header labels below are translated with THIS i18n service,
1125+
// which `extractLocale` also reads the request's locale from (its
1126+
// default locale is the last fallback), and the boolean cells are
1127+
// written in the words of the same locale — the pair the import
1128+
// template offers (`templateText`, `./import-template.ts`). One
1129+
// service and one locale, so the header and the cells cannot read
1130+
// two different languages.
1131+
const i18n = await this.resolveI18nService(environmentId, req).catch(() => undefined);
1132+
const booleanWords = templateText(this.extractLocale(req, i18n)).booleanWords;
11141133
try {
11151134
let schema: any = await readObjectSchema(objectName);
11161135
// Localize field labels to the request locale (Accept-Language /
11171136
// `?locale=`) the same way the metadata endpoints do, so the
11181137
// export header row matches the UI column headers instead of
11191138
// leaking the raw, untranslated `field.label` values.
1120-
schema = await this.translateMetaItem(req, 'object', environmentId, schema);
1139+
schema = await this.translateMetaItem(req, 'object', environmentId, schema, i18n);
11211140
if (typeof schema?.label === 'string' && schema.label.length > 0) {
11221141
objectLabel = schema.label;
11231142
}
@@ -1291,15 +1310,15 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
12911310
}
12921311

12931312
if (format === 'csv') {
1294-
const text = rowsToCsv(fields ?? [], rows, firstChunk && includeHeader, metaMap, timezone);
1313+
const text = rowsToCsv(fields ?? [], rows, firstChunk && includeHeader, metaMap, timezone, booleanWords);
12951314
res.write(text);
12961315
} else if (format === 'xlsx') {
12971316
if (firstChunk && includeHeader) {
12981317
xlsx!.ws.addRow((fields ?? []).map((f) => headerLabel(f, metaMap))).commit();
12991318
}
13001319
const cols = fields ?? [];
13011320
for (const row of rows) {
1302-
const r = xlsx!.ws.addRow(formatRowCells(row, cols, metaMap, timezone));
1321+
const r = xlsx!.ws.addRow(formatRowCells(row, cols, metaMap, timezone, booleanWords));
13031322
if (styled) {
13041323
cols.forEach((f, i) => {
13051324
const argb = cellFontColor(row?.[f], metaMap.get(f));
@@ -1320,7 +1339,7 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
13201339
for (let i = 0; i < rows.length; i++) {
13211340
const prefix = (firstChunk && i === 0) ? '' : ',';
13221341
const row = fieldsExplicit ? pickExportKeys(rows[i], fields!) : rows[i];
1323-
res.write(prefix + JSON.stringify(formatRowForJson(row, metaMap, timezone)));
1342+
res.write(prefix + JSON.stringify(formatRowForJson(row, metaMap, timezone, booleanWords)));
13241343
}
13251344
}
13261345
firstChunk = false;

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

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333
import { temporalStorageForm, type ExportFieldMeta } from '@objectstack/core';
3434
import { resolveDisplayField } from '@objectstack/spec/data';
35+
import { templateText, type TemplateText } from './import-template.js';
3536

3637
// `ExportFieldMeta` and `buildFieldMetaMap` live in `@objectstack/core`
3738
// (`utils/import-field-meta.ts`), beside the import runner that reads them.
@@ -433,24 +434,43 @@ function formatReference(value: unknown, displayField?: string, titleField?: str
433434
return one(value);
434435
}
435436

437+
/**
438+
* The words a boolean / toggle cell is written as, `[true, false]`.
439+
*
440+
* [#24945] The export route resolves them once per export, as
441+
* `templateText(locale).booleanWords` (`./import-template.ts`) for the locale
442+
* its header row is translated to, so the header and the cells of one file read
443+
* one language. That is the table the import template already offers the same
444+
* words from, and the importer reads both of its languages back
445+
* (`IMPORT_BOOLEAN_TRUE_TOKENS` / `IMPORT_BOOLEAN_FALSE_TOKENS`), so a file
446+
* exported in either language re-imports. ⛔ No second table here.
447+
*/
448+
export type ExportBooleanWords = TemplateText['booleanWords'];
449+
436450
/**
437451
* Format one storage value into a display value using its field metadata.
438452
*
439453
* `timezone` is the request's business timezone (`ExecutionContext.timezone`).
440454
* It reaches only the `datetime` branch; absent, the cell renders in UTC —
441455
* see {@link formatDate} for why `date` never reads it at all.
456+
*
457+
* `booleanWords` ({@link ExportBooleanWords}) reaches only the boolean /
458+
* toggle branch; absent, the cell is written in the words the table answers
459+
* for no locale at all.
442460
*/
443461
export function formatCellValue(
444462
value: unknown,
445463
meta?: ExportCellMeta,
446464
timezone?: string,
465+
booleanWords: ExportBooleanWords = templateText(undefined).booleanWords,
447466
): unknown {
448467
if (value === null || value === undefined) return value;
449468
if (!meta || !meta.type) return value;
450469
const t = meta.type;
451470
if (t === 'boolean' || t === 'toggle') {
452-
if (value === true || value === 'true' || value === 1) return '是';
453-
if (value === false || value === 'false' || value === 0) return '否';
471+
const [yes, no] = booleanWords;
472+
if (value === true || value === 'true' || value === 1) return yes;
473+
if (value === false || value === 'false' || value === 0) return no;
454474
return value;
455475
}
456476
if (OPTION_TYPES.has(t)) return optionLabel(value, meta.options);
@@ -467,16 +487,18 @@ export function formatCellValue(
467487
/**
468488
* Ordered display cells for one row — the CSV / XLSX column path.
469489
*
470-
* `timezone` is threaded straight through to {@link formatCellValue}; the
471-
* export route reads it off the `ExecutionContext` it already resolved.
490+
* `timezone` and `booleanWords` are threaded straight through to
491+
* {@link formatCellValue}; the export route reads the timezone off the
492+
* `ExecutionContext` it already resolved, and the words off the request locale.
472493
*/
473494
export function formatRowCells(
474495
row: Record<string, unknown>,
475496
fields: string[],
476497
metaMap: Map<string, ExportCellMeta>,
477498
timezone?: string,
499+
booleanWords?: ExportBooleanWords,
478500
): unknown[] {
479-
return fields.map((f) => formatCellValue(row?.[f], metaMap.get(f), timezone));
501+
return fields.map((f) => formatCellValue(row?.[f], metaMap.get(f), timezone, booleanWords));
480502
}
481503

482504
/**
@@ -488,13 +510,14 @@ export function formatRowForJson(
488510
row: Record<string, unknown>,
489511
metaMap: Map<string, ExportCellMeta>,
490512
timezone?: string,
513+
booleanWords?: ExportBooleanWords,
491514
): Record<string, unknown> {
492515
if (metaMap.size === 0 || !row || typeof row !== 'object') return row;
493516
let copy: Record<string, unknown> | null = null;
494517
for (const key of Object.keys(row)) {
495518
const meta = metaMap.get(key);
496519
if (!meta) continue;
497-
const formatted = formatCellValue(row[key], meta, timezone);
520+
const formatted = formatCellValue(row[key], meta, timezone, booleanWords);
498521
if (formatted !== row[key]) {
499522
if (!copy) copy = { ...row };
500523
copy[key] = formatted;

‎packages/rest/src/export-integration.test.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ const TASK = {
9393
},
9494
};
9595

96+
/**
97+
* [#24945] The caller these Chinese fixtures are read by. A boolean cell is
98+
* written in the caller's language (`templateText(locale).booleanWords`), so a
99+
* pin on `是` / `否` names a Chinese caller; a caller naming no locale gets the
100+
* words `templateText(undefined)` answers (`export-boolean-locale.test.ts`).
101+
*/
102+
const ZH_CALLER = { 'accept-language': 'zh-CN' };
103+
96104
function createMockServer() {
97105
const noop = () => {};
98106
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
@@ -191,7 +199,7 @@ describe('export route — real engine + protocol integration', () => {
191199

192200
it('CSV: formats every value type readably; owner name comes from a REAL $expand', async () => {
193201
const { res, chunks, headers } = makeRes();
194-
await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, res);
202+
await route.handler({ params: { object: 'task' }, query: { format: 'csv' }, headers: ZH_CALLER } as any, res);
195203

196204
expect(headers['Content-Type']).toBe('text/csv; charset=utf-8');
197205
const rows = parseCsv(chunks.join(''));
@@ -213,7 +221,7 @@ describe('export route — real engine + protocol integration', () => {
213221

214222
it('XLSX: opens as a real workbook with formatted cells', async () => {
215223
const { res, getBuffer, headers } = makeBinRes();
216-
await route.handler({ params: { object: 'task' }, query: { format: 'xlsx' } } as any, res);
224+
await route.handler({ params: { object: 'task' }, query: { format: 'xlsx' }, headers: ZH_CALLER } as any, res);
217225

218226
expect(headers['Content-Type']).toBe(
219227
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
@@ -247,7 +255,7 @@ describe('export route — real engine + protocol integration', () => {
247255
it('XLSX: exceeding the style cap drops styling but keeps all rows', async () => {
248256
const { res, getBuffer, headers } = makeBinRes();
249257
await route.handler(
250-
{ params: { object: 'task' }, query: { format: 'xlsx', limit: '20000' } } as any,
258+
{ params: { object: 'task' }, query: { format: 'xlsx', limit: '20000' }, headers: ZH_CALLER } as any,
251259
res,
252260
);
253261

@@ -263,7 +271,7 @@ describe('export route — real engine + protocol integration', () => {
263271

264272
it('JSON: readable values, all rows present', async () => {
265273
const { res, chunks, headers } = makeRes();
266-
await route.handler({ params: { object: 'task' }, query: { format: 'json' } } as any, res);
274+
await route.handler({ params: { object: 'task' }, query: { format: 'json' }, headers: ZH_CALLER } as any, res);
267275

268276
expect(headers['Content-Type']).toBe('application/json; charset=utf-8');
269277
const arr = JSON.parse(chunks.join(''));
@@ -276,6 +284,7 @@ describe('export route — real engine + protocol integration', () => {
276284
await route.handler({
277285
params: { object: 'task' },
278286
query: { format: 'csv', filter: JSON.stringify({ done: true }), orderby: 'id:desc' },
287+
headers: ZH_CALLER,
279288
} as any, res);
280289

281290
const dataRows = parseCsv(chunks.join('')).slice(1);

‎packages/rest/src/export-json-fields.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ afterEach(async () => {
4949
/**
5050
* `title` is the name field, so the companion's real producer declares
5151
* `__search` on this object (and the read strips its value). `done` is a
52-
* boolean, so a projected value is visibly formatted (`是` / `否`).
52+
* boolean, so a projected value is visibly formatted (`yes` / `no`: the
53+
* requests name no locale, so the cells take the words `templateText(undefined)`
54+
* answers, #24945).
5355
*/
5456
const NOTE = provisionSearchCompanion({
5557
name: 'note',
@@ -161,8 +163,8 @@ describe('format=json honours an explicit ?fields= (#24183)', () => {
161163
['done', 'internal_code', 'title', 'id'],
162164
['done', 'internal_code', 'title', 'id'],
163165
]);
164-
expect(rows[0]).toEqual({ done: '是', internal_code: 'IC-1', title: 'Note 1', id: '1' });
165-
expect(rows[1]).toEqual({ done: '否', internal_code: 'IC-2', title: 'Note 2', id: '2' });
166+
expect(rows[0]).toEqual({ done: 'yes', internal_code: 'IC-1', title: 'Note 1', id: '1' });
167+
expect(rows[1]).toEqual({ done: 'no', internal_code: 'IC-2', title: 'Note 2', id: '2' });
166168
});
167169

168170
it('JSON: a named key the read did not return is absent, never invented as null (the stripped companion)', async () => {

‎packages/rest/src/import-template-route.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,7 +777,10 @@ async function exportOnce(query: Record<string, string>, security?: Record<strin
777777
try {
778778
const { route, findData } = await bootExportFixture(security);
779779
const out = makeRes();
780-
await route.handler({ params: { object: 'task' }, query } as any, out.res);
780+
// [#24945] A Chinese caller: a boolean cell follows the caller's language
781+
// now, and the recorded bytes below are this Chinese fixture's `是` / `否`
782+
// file — the bytes every caller got before, and a zh-CN caller still gets.
783+
await route.handler({ params: { object: 'task' }, query, headers: { 'accept-language': 'zh-CN' } } as any, out.res);
781784
return { ...out, findData };
782785
} finally {
783786
vi.useRealTimers();

‎packages/rest/src/import-template.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,10 @@ import {
7272
isMultiValueField,
7373
} from '@objectstack/spec/data';
7474
import type { ISecurityService, SecurityContext } from '@objectstack/spec/contracts';
75-
import { buildFieldMetaMap, type ExportFieldMeta } from './export-format.js';
75+
// From core, where they live, not through `./export-format.js`'s re-export:
76+
// that module reads `templateText` from here (#24945), and the two modules do
77+
// not import each other.
78+
import { buildFieldMetaMap, type ExportFieldMeta } from '@objectstack/core';
7679
import { loadExcelJs, type Workbook, type Worksheet } from './xlsx-module.js';
7780

7881
// ── the column rule ─────────────────────────────────────────────────

‎packages/rest/src/rest-server.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ import {
285285
headerLabel,
286286
formatRowCells,
287287
exportContentDisposition,
288+
type ExportBooleanWords,
288289
type ExportFieldMeta,
289290
} from './export-format.js';
290291
// [#23050 slice 1] The data-action routes' body moved to its own module — see its header.
@@ -1142,23 +1143,27 @@ function formatCsvCell(value: any): string {
11421143
* Serialise a list of rows to RFC-4180 CSV text. Caller supplies the ordered
11431144
* list of field names and a (possibly empty) field-metadata map. With metadata,
11441145
* the header row uses field labels and cell values are formatted to readable
1145-
* display values (lookup names, select labels, 是/否, formatted dates). With an
1146-
* empty map the output is byte-identical to the raw, un-formatted behaviour.
1146+
* display values (lookup names, select labels, yes / no words, formatted
1147+
* dates). With an empty map the output is byte-identical to the raw,
1148+
* un-formatted behaviour.
11471149
*
11481150
* `timezone` (#8373) is the caller's business timezone, used to render
11491151
* `datetime` cells in the same clock the UI shows; absent, they render in UTC.
1152+
* `booleanWords` (#24945) are the words of the caller's language that a boolean
1153+
* cell is written as (`ExportBooleanWords` in `./export-format.ts`).
11501154
*/
11511155
export function rowsToCsv(
11521156
fields: string[],
11531157
rows: Array<Record<string, any>>,
11541158
includeHeader: boolean,
11551159
metaMap: Map<string, ExportFieldMeta>,
11561160
timezone?: string,
1161+
booleanWords?: ExportBooleanWords,
11571162
): string {
11581163
const lines: string[] = [];
11591164
if (includeHeader) lines.push(fields.map(f => formatCsvCell(headerLabel(f, metaMap))).join(','));
11601165
for (const row of rows) {
1161-
lines.push(formatRowCells(row, fields, metaMap, timezone).map(formatCsvCell).join(','));
1166+
lines.push(formatRowCells(row, fields, metaMap, timezone, booleanWords).map(formatCsvCell).join(','));
11621167
}
11631168
return lines.join('\r\n') + (lines.length > 0 ? '\r\n' : '');
11641169
}
@@ -5528,6 +5533,8 @@ export class RestServer {
55285533
translateMetaItem: (...a) => this.translateMetaItem(...a),
55295534
resolveMessageTranslator: (...a) => this.resolveMessageTranslator(...a),
55305535
resolveSecurityService: (...a) => this.resolveSecurityService(...a),
5536+
resolveI18nService: (...a) => this.resolveI18nService(...a),
5537+
extractLocale: (...a) => this.extractLocale(...a),
55315538
answerImportTemplate: (...a) => this.answerImportTemplate(...a),
55325539
};
55335540
registerDataActionEndpoints.call(host, basePath);

0 commit comments

Comments
 (0)