From d913467d6ed914993aaa39457290184f410bed7e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:01:46 +0000 Subject: [PATCH 1/4] test(rest): pin export boolean cells to the caller's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red on main: an Accept-Language en-US caller gets the English header row over boolean cells written as the literal 是 / 否, in csv, xlsx and json. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .../rest/src/export-boolean-locale.test.ts | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 packages/rest/src/export-boolean-locale.test.ts diff --git a/packages/rest/src/export-boolean-locale.test.ts b/packages/rest/src/export-boolean-locale.test.ts new file mode 100644 index 00000000000..a343a1879ea --- /dev/null +++ b/packages/rest/src/export-boolean-locale.test.ts @@ -0,0 +1,256 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24945] A boolean or toggle cell of `GET /data/:object/export` is written in + * the caller's language — the same language as the header row of that file. + * + * The export formatter used to write the literal `是` / `否` for every boolean + * cell, whatever the caller read: an `Accept-Language: en-US` caller got the + * English header row ("Converted", "Do Not Call") over Chinese cells. The words + * now come from the one table the import template already offers them from, + * `templateText(locale).booleanWords` (`import-template.ts`), for the locale the + * route resolves for its header labels — the request's i18n service read once + * and handed to both the header translation and the words, so the two halves of + * the file cannot read two different locales. + * + * The file must stay importable in either language: the last block exports in + * each language and re-imports the file through the real import route into a + * fresh store, so the boolean column has to read back as the values it left as. + * + * Driven end to end: the real export and import routes, a real ObjectQL engine + * and protocol, and a real sqlite `:memory:` driver (the harness of + * `export-integration.test.ts`). + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; +import { RestServer } from './rest-server'; +import { templateText } from './import-template.js'; +import { loadXlsxWorkbook } from './xlsx-test-loader.js'; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** Authored in English; the zh-CN bundle below translates every label. */ +const LEAD = { + name: 'lead', + label: 'Lead', + systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + full_name: { name: 'full_name', type: 'text' as const, label: 'Full Name' }, + is_converted: { name: 'is_converted', type: 'boolean' as const, label: 'Converted' }, + do_not_call: { name: 'do_not_call', type: 'toggle' as const, label: 'Do Not Call' }, + }, +}; + +const BUNDLE: Record = { + 'zh-CN': { + objects: { + lead: { + label: '线索', + fields: { + id: { label: '编号' }, + full_name: { label: '姓名' }, + is_converted: { label: '已转化' }, + do_not_call: { label: '请勿致电' }, + }, + }, + }, + }, +}; + +/** An i18n service of the shape `@objectstack/service-i18n` serves. */ +function i18nService(defaultLocale?: string) { + return { + getLocales: () => Object.keys(BUNDLE), + getTranslations: (locale: string) => BUNDLE[locale], + ...(defaultLocale ? { getDefaultLocale: () => defaultLocale } : {}), + }; +} + +const ROWS = [ + { id: '1', full_name: 'Wren Halvorsen', is_converted: true, do_not_call: false }, + { id: '2', full_name: 'Tomas Okafor', is_converted: false, do_not_call: true }, +]; + +const EN_HEADER = ['ID', 'Full Name', 'Converted', 'Do Not Call']; +const ZH_HEADER = ['编号', '姓名', '已转化', '请勿致电']; +const EN_CELLS = [['1', 'Wren Halvorsen', 'yes', 'no'], ['2', 'Tomas Okafor', 'no', 'yes']]; +const ZH_CELLS = [['1', 'Wren Halvorsen', '是', '否'], ['2', 'Tomas Okafor', '否', '是']]; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const chunks: Buffer[] = []; + const headers: Record = {}; + const res: any = { + write: (c: any) => { chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))); return true; }, + end: () => {}, + header: (n: string, v: string) => { headers[n] = v; return res; }, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return { res, headers, text: () => Buffer.concat(chunks).toString('utf8'), buffer: () => Buffer.concat(chunks) }; +} + +/** + * Boot one engine + REST server. `i18n` is the request's i18n service: + * `null` leaves the server's own resolution in place (none in this harness). + */ +async function boot(i18n: unknown, rows: ReadonlyArray> = ROWS) { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + engine.registry.registerObject(LEAD as any); + await engine.syncSchemas(); + for (const row of rows) await engine.insert('lead', { ...row }); + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + if (i18n !== null) (rest as any).resolveI18nService = async () => i18n; + rest.registerRoutes(); + const routes = rest.getRoutes(); + const exportRoute = routes.find((r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export')!; + const importRoute = routes.find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/import')!; + return { engine, exportRoute, importRoute }; +} + +async function exportFile( + route: any, + format: 'csv' | 'xlsx' | 'json', + opts: { acceptLanguage?: string; locale?: string } = {}, +) { + const { res, text, buffer } = makeRes(); + await route.handler({ + params: { object: 'lead' }, + query: { format, ...(opts.locale ? { locale: opts.locale } : {}) }, + headers: opts.acceptLanguage ? { 'accept-language': opts.acceptLanguage } : {}, + } as any, res); + return { text, buffer }; +} + +function csvRows(text: string): string[][] { + return text.split('\r\n').filter((l) => l.length > 0).map((l) => l.split(',')); +} + +async function xlsxRows(buffer: Buffer): Promise { + const ws = (await loadXlsxWorkbook(buffer)).worksheets[0]; + const out: string[][] = []; + ws.eachRow((row) => { out.push((row.values as unknown[]).slice(1).map((v) => String(v))); }); + return out; +} + +describe('export: a boolean cell speaks the language of its header row', () => { + it('an English caller gets yes / no under the English header (csv)', async () => { + const { exportRoute } = await boot(i18nService('en')); + const rows = csvRows((await exportFile(exportRoute, 'csv', { acceptLanguage: 'en-US' })).text()); + expect(rows).toEqual([EN_HEADER, ...EN_CELLS]); + }); + + it('a Chinese caller gets 是 / 否 under the Chinese header (csv)', async () => { + const { exportRoute } = await boot(i18nService('en')); + const rows = csvRows((await exportFile(exportRoute, 'csv', { acceptLanguage: 'zh-CN' })).text()); + expect(rows).toEqual([ZH_HEADER, ...ZH_CELLS]); + }); + + it('xlsx writes the same words as csv, per caller', async () => { + const { exportRoute } = await boot(i18nService('en')); + expect(await xlsxRows((await exportFile(exportRoute, 'xlsx', { acceptLanguage: 'en-US' })).buffer())) + .toEqual([EN_HEADER, ...EN_CELLS]); + expect(await xlsxRows((await exportFile(exportRoute, 'xlsx', { acceptLanguage: 'zh-CN' })).buffer())) + .toEqual([ZH_HEADER, ...ZH_CELLS]); + }); + + it('json writes the same words as csv, per caller', async () => { + const { exportRoute } = await boot(i18nService('en')); + const en = JSON.parse((await exportFile(exportRoute, 'json', { acceptLanguage: 'en-US' })).text()); + expect(en.map((r: any) => [r.is_converted, r.do_not_call])).toEqual([['yes', 'no'], ['no', 'yes']]); + const zh = JSON.parse((await exportFile(exportRoute, 'json', { acceptLanguage: 'zh-CN' })).text()); + expect(zh.map((r: any) => [r.is_converted, r.do_not_call])).toEqual([['是', '否'], ['否', '是']]); + }); + + it('`?locale=` picks the language of the header and the cells alike', async () => { + const { exportRoute } = await boot(i18nService('en')); + const rows = csvRows((await exportFile(exportRoute, 'csv', { locale: 'zh-CN' })).text()); + expect(rows).toEqual([ZH_HEADER, ...ZH_CELLS]); + }); + + it('a caller naming no locale gets the deployment default locale in the header and the cells alike', async () => { + // The default locale is read off the i18n service. Both halves of the + // file must read it from the SAME service, or a zh-CN deployment would + // write a Chinese header over English cells to every header-less caller. + const zh = await boot(i18nService('zh-CN')); + expect(csvRows((await exportFile(zh.exportRoute, 'csv')).text())).toEqual([ZH_HEADER, ...ZH_CELLS]); + const en = await boot(i18nService('en')); + expect(csvRows((await exportFile(en.exportRoute, 'csv')).text())).toEqual([EN_HEADER, ...EN_CELLS]); + }); + + it('no locale at all and no i18n service: the authored header and templateText(undefined) words', async () => { + const { exportRoute } = await boot(null); + const [yes, no] = templateText(undefined).booleanWords; + const rows = csvRows((await exportFile(exportRoute, 'csv')).text()); + expect(rows).toEqual([EN_HEADER, ['1', 'Wren Halvorsen', yes, no], ['2', 'Tomas Okafor', no, yes]]); + }); +}); + +describe('export → import: each language re-imports its boolean column', () => { + const MAPPINGS: Record<'en' | 'zh', Record> = { + en: { 'ID': 'id', 'Full Name': 'full_name', 'Converted': 'is_converted', 'Do Not Call': 'do_not_call' }, + zh: { '编号': 'id', '姓名': 'full_name', '已转化': 'is_converted', '请勿致电': 'do_not_call' }, + }; + + for (const [lang, acceptLanguage] of [['en', 'en-US'], ['zh', 'zh-CN']] as const) { + it(`${lang}: csv`, async () => { + const source = await boot(i18nService('en')); + const csv = (await exportFile(source.exportRoute, 'csv', { acceptLanguage })).text(); + + const target = await boot(i18nService('en'), []); + const { res } = makeRes(); + await target.importRoute.handler({ + params: { object: 'lead' }, + headers: { 'accept-language': acceptLanguage }, + body: { format: 'csv', csv, mapping: MAPPINGS[lang] }, + } as any, res); + expect(res._json).toMatchObject({ total: 2, ok: 2, errors: 0, created: 2 }); + const stored = await target.engine.find('lead', { orderBy: [{ field: 'id', order: 'asc' }] } as any); + expect(stored.map((r: any) => [r.id, r.is_converted, r.do_not_call])) + .toEqual([['1', true, false], ['2', false, true]]); + }); + + it(`${lang}: xlsx`, async () => { + const source = await boot(i18nService('en')); + const xlsxBase64 = (await exportFile(source.exportRoute, 'xlsx', { acceptLanguage })).buffer().toString('base64'); + + const target = await boot(i18nService('en'), []); + const { res } = makeRes(); + await target.importRoute.handler({ + params: { object: 'lead' }, + headers: { 'accept-language': acceptLanguage }, + body: { format: 'xlsx', xlsxBase64, mapping: MAPPINGS[lang] }, + } as any, res); + expect(res._json).toMatchObject({ total: 2, ok: 2, errors: 0, created: 2 }); + const stored = await target.engine.find('lead', { orderBy: [{ field: 'id', order: 'asc' }] } as any); + expect(stored.map((r: any) => [r.id, r.is_converted, r.do_not_call])) + .toEqual([['1', true, false], ['2', false, true]]); + }); + } +}); From d45a9385bfe52b1aeeec3a0d6911b9cbb18b533d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:05:11 +0000 Subject: [PATCH 2/4] fix(rest): export boolean cells follow the caller's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/rest/src/data-action-routes.ts | 35 ++++++++++++++----- packages/rest/src/export-format.ts | 35 +++++++++++++++---- packages/rest/src/export-integration.test.ts | 17 ++++++--- packages/rest/src/export-json-fields.test.ts | 8 +++-- .../rest/src/import-template-route.test.ts | 5 ++- packages/rest/src/import-template.ts | 5 ++- packages/rest/src/rest-server.ts | 13 +++++-- packages/rest/src/rest.test.ts | 9 ++--- 8 files changed, 97 insertions(+), 30 deletions(-) diff --git a/packages/rest/src/data-action-routes.ts b/packages/rest/src/data-action-routes.ts index 8c2c5eb139f..53f7b7e9e90 100644 --- a/packages/rest/src/data-action-routes.ts +++ b/packages/rest/src/data-action-routes.ts @@ -68,7 +68,7 @@ import { type ExportCellMeta, } from './export-format.js'; import { importControlOptOutRefusal, prepareImportRequest } from './import-prepare.js'; -import { readTemplateMode, templateFieldDefs } from './import-template.js'; +import { readTemplateMode, templateFieldDefs, templateText } from './import-template.js'; import { refuseRepeatedQueryParams } from './query-multiplicity.js'; import { refuseUnknownQueryParams } from './query-allowlist.js'; import { handleRouteError, logUnexpectedRouteError, mapDataError, nestedRefusalEnvelope } from './error-response.js'; @@ -200,6 +200,8 @@ export interface DataActionRouteHost { translateMetaItem: RestServer['translateMetaItem']; resolveMessageTranslator: RestServer['resolveMessageTranslator']; resolveSecurityService: RestServer['resolveSecurityService']; + resolveI18nService: RestServer['resolveI18nService']; + extractLocale: RestServer['extractLocale']; answerImportTemplate: RestServer['answerImportTemplate']; } @@ -884,8 +886,15 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: // // Values are formatted for readability from the object schema: lookup / // user fields resolve to a name (via injected $expand), select fields to - // their option label, booleans to 是/否, dates to YYYY-MM-DD. When the - // schema is unavailable the raw stored values stream through unchanged. + // their option label, booleans to the caller's yes / no words, dates to + // YYYY-MM-DD. When the schema is unavailable the raw stored values stream + // through unchanged. + // + // [#24945] Boolean / toggle cells are written in the language of the + // header row: `templateText(locale).booleanWords` (yes / no, or 是 / 否 for + // a `zh*` locale), for the locale the header labels are translated to + // (`Accept-Language`, then `?locale=`, then the i18n service's default). + // The importer reads both languages back, so the file re-imports. // // [#8373] `datetime` cells render in the caller's BUSINESS timezone // (`ExecutionContext.timezone`), so the file agrees with the screen; @@ -1084,7 +1093,7 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: const fieldsExplicit = !!fields && fields.length > 0; // Field metadata drives readable formatting (lookup names, select - // labels, 是/否, formatted dates) and the $expand that resolves + // labels, yes / no words, formatted dates) and the $expand that resolves // references. Best-effort: when the schema is unavailable the export // falls back to raw values, byte-identical to the un-formatted path. let metaMap = new Map(); @@ -1111,13 +1120,23 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: } return schema; }; + // [#24945] The file's language, read once for both of its halves: + // the header labels below are translated with THIS i18n service, + // which `extractLocale` also reads the request's locale from (its + // default locale is the last fallback), and the boolean cells are + // written in the words of the same locale — the pair the import + // template offers (`templateText`, `./import-template.ts`). One + // service and one locale, so the header and the cells cannot read + // two different languages. + const i18n = await this.resolveI18nService(environmentId, req).catch(() => undefined); + const booleanWords = templateText(this.extractLocale(req, i18n)).booleanWords; try { let schema: any = await readObjectSchema(objectName); // Localize field labels to the request locale (Accept-Language / // `?locale=`) the same way the metadata endpoints do, so the // export header row matches the UI column headers instead of // leaking the raw, untranslated `field.label` values. - schema = await this.translateMetaItem(req, 'object', environmentId, schema); + schema = await this.translateMetaItem(req, 'object', environmentId, schema, i18n); if (typeof schema?.label === 'string' && schema.label.length > 0) { objectLabel = schema.label; } @@ -1291,7 +1310,7 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: } if (format === 'csv') { - const text = rowsToCsv(fields ?? [], rows, firstChunk && includeHeader, metaMap, timezone); + const text = rowsToCsv(fields ?? [], rows, firstChunk && includeHeader, metaMap, timezone, booleanWords); res.write(text); } else if (format === 'xlsx') { if (firstChunk && includeHeader) { @@ -1299,7 +1318,7 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: } const cols = fields ?? []; for (const row of rows) { - const r = xlsx!.ws.addRow(formatRowCells(row, cols, metaMap, timezone)); + const r = xlsx!.ws.addRow(formatRowCells(row, cols, metaMap, timezone, booleanWords)); if (styled) { cols.forEach((f, i) => { const argb = cellFontColor(row?.[f], metaMap.get(f)); @@ -1320,7 +1339,7 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: for (let i = 0; i < rows.length; i++) { const prefix = (firstChunk && i === 0) ? '' : ','; const row = fieldsExplicit ? pickExportKeys(rows[i], fields!) : rows[i]; - res.write(prefix + JSON.stringify(formatRowForJson(row, metaMap, timezone))); + res.write(prefix + JSON.stringify(formatRowForJson(row, metaMap, timezone, booleanWords))); } } firstChunk = false; diff --git a/packages/rest/src/export-format.ts b/packages/rest/src/export-format.ts index 6fd4e383032..42ece978e36 100644 --- a/packages/rest/src/export-format.ts +++ b/packages/rest/src/export-format.ts @@ -32,6 +32,7 @@ import { temporalStorageForm, type ExportFieldMeta } from '@objectstack/core'; import { resolveDisplayField } from '@objectstack/spec/data'; +import { templateText, type TemplateText } from './import-template.js'; // `ExportFieldMeta` and `buildFieldMetaMap` live in `@objectstack/core` // (`utils/import-field-meta.ts`), beside the import runner that reads them. @@ -433,24 +434,43 @@ function formatReference(value: unknown, displayField?: string, titleField?: str return one(value); } +/** + * The words a boolean / toggle cell is written as, `[true, false]`. + * + * [#24945] The export route resolves them once per export, as + * `templateText(locale).booleanWords` (`./import-template.ts`) for the locale + * its header row is translated to, so the header and the cells of one file read + * one language. That is the table the import template already offers the same + * words from, and the importer reads both of its languages back + * (`IMPORT_BOOLEAN_TRUE_TOKENS` / `IMPORT_BOOLEAN_FALSE_TOKENS`), so a file + * exported in either language re-imports. ⛔ No second table here. + */ +export type ExportBooleanWords = TemplateText['booleanWords']; + /** * Format one storage value into a display value using its field metadata. * * `timezone` is the request's business timezone (`ExecutionContext.timezone`). * It reaches only the `datetime` branch; absent, the cell renders in UTC — * see {@link formatDate} for why `date` never reads it at all. + * + * `booleanWords` ({@link ExportBooleanWords}) reaches only the boolean / + * toggle branch; absent, the cell is written in the words the table answers + * for no locale at all. */ export function formatCellValue( value: unknown, meta?: ExportCellMeta, timezone?: string, + booleanWords: ExportBooleanWords = templateText(undefined).booleanWords, ): unknown { if (value === null || value === undefined) return value; if (!meta || !meta.type) return value; const t = meta.type; if (t === 'boolean' || t === 'toggle') { - if (value === true || value === 'true' || value === 1) return '是'; - if (value === false || value === 'false' || value === 0) return '否'; + const [yes, no] = booleanWords; + if (value === true || value === 'true' || value === 1) return yes; + if (value === false || value === 'false' || value === 0) return no; return value; } if (OPTION_TYPES.has(t)) return optionLabel(value, meta.options); @@ -467,16 +487,18 @@ export function formatCellValue( /** * Ordered display cells for one row — the CSV / XLSX column path. * - * `timezone` is threaded straight through to {@link formatCellValue}; the - * export route reads it off the `ExecutionContext` it already resolved. + * `timezone` and `booleanWords` are threaded straight through to + * {@link formatCellValue}; the export route reads the timezone off the + * `ExecutionContext` it already resolved, and the words off the request locale. */ export function formatRowCells( row: Record, fields: string[], metaMap: Map, timezone?: string, + booleanWords?: ExportBooleanWords, ): unknown[] { - return fields.map((f) => formatCellValue(row?.[f], metaMap.get(f), timezone)); + return fields.map((f) => formatCellValue(row?.[f], metaMap.get(f), timezone, booleanWords)); } /** @@ -488,13 +510,14 @@ export function formatRowForJson( row: Record, metaMap: Map, timezone?: string, + booleanWords?: ExportBooleanWords, ): Record { if (metaMap.size === 0 || !row || typeof row !== 'object') return row; let copy: Record | null = null; for (const key of Object.keys(row)) { const meta = metaMap.get(key); if (!meta) continue; - const formatted = formatCellValue(row[key], meta, timezone); + const formatted = formatCellValue(row[key], meta, timezone, booleanWords); if (formatted !== row[key]) { if (!copy) copy = { ...row }; copy[key] = formatted; diff --git a/packages/rest/src/export-integration.test.ts b/packages/rest/src/export-integration.test.ts index 28d33d2194e..b1b4652c27f 100644 --- a/packages/rest/src/export-integration.test.ts +++ b/packages/rest/src/export-integration.test.ts @@ -93,6 +93,14 @@ const TASK = { }, }; +/** + * [#24945] The caller these Chinese fixtures are read by. A boolean cell is + * written in the caller's language (`templateText(locale).booleanWords`), so a + * pin on `是` / `否` names a Chinese caller; a caller naming no locale gets the + * words `templateText(undefined)` answers (`export-boolean-locale.test.ts`). + */ +const ZH_CALLER = { 'accept-language': 'zh-CN' }; + function createMockServer() { const noop = () => {}; 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', () => { it('CSV: formats every value type readably; owner name comes from a REAL $expand', async () => { const { res, chunks, headers } = makeRes(); - await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, res); + await route.handler({ params: { object: 'task' }, query: { format: 'csv' }, headers: ZH_CALLER } as any, res); expect(headers['Content-Type']).toBe('text/csv; charset=utf-8'); const rows = parseCsv(chunks.join('')); @@ -213,7 +221,7 @@ describe('export route — real engine + protocol integration', () => { it('XLSX: opens as a real workbook with formatted cells', async () => { const { res, getBuffer, headers } = makeBinRes(); - await route.handler({ params: { object: 'task' }, query: { format: 'xlsx' } } as any, res); + await route.handler({ params: { object: 'task' }, query: { format: 'xlsx' }, headers: ZH_CALLER } as any, res); expect(headers['Content-Type']).toBe( 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', @@ -247,7 +255,7 @@ describe('export route — real engine + protocol integration', () => { it('XLSX: exceeding the style cap drops styling but keeps all rows', async () => { const { res, getBuffer, headers } = makeBinRes(); await route.handler( - { params: { object: 'task' }, query: { format: 'xlsx', limit: '20000' } } as any, + { params: { object: 'task' }, query: { format: 'xlsx', limit: '20000' }, headers: ZH_CALLER } as any, res, ); @@ -263,7 +271,7 @@ describe('export route — real engine + protocol integration', () => { it('JSON: readable values, all rows present', async () => { const { res, chunks, headers } = makeRes(); - await route.handler({ params: { object: 'task' }, query: { format: 'json' } } as any, res); + await route.handler({ params: { object: 'task' }, query: { format: 'json' }, headers: ZH_CALLER } as any, res); expect(headers['Content-Type']).toBe('application/json; charset=utf-8'); const arr = JSON.parse(chunks.join('')); @@ -276,6 +284,7 @@ describe('export route — real engine + protocol integration', () => { await route.handler({ params: { object: 'task' }, query: { format: 'csv', filter: JSON.stringify({ done: true }), orderby: 'id:desc' }, + headers: ZH_CALLER, } as any, res); const dataRows = parseCsv(chunks.join('')).slice(1); diff --git a/packages/rest/src/export-json-fields.test.ts b/packages/rest/src/export-json-fields.test.ts index 6d4d7602d42..d9a0231dbc7 100644 --- a/packages/rest/src/export-json-fields.test.ts +++ b/packages/rest/src/export-json-fields.test.ts @@ -49,7 +49,9 @@ afterEach(async () => { /** * `title` is the name field, so the companion's real producer declares * `__search` on this object (and the read strips its value). `done` is a - * boolean, so a projected value is visibly formatted (`是` / `否`). + * boolean, so a projected value is visibly formatted (`yes` / `no`: the + * requests name no locale, so the cells take the words `templateText(undefined)` + * answers, #24945). */ const NOTE = provisionSearchCompanion({ name: 'note', @@ -161,8 +163,8 @@ describe('format=json honours an explicit ?fields= (#24183)', () => { ['done', 'internal_code', 'title', 'id'], ['done', 'internal_code', 'title', 'id'], ]); - expect(rows[0]).toEqual({ done: '是', internal_code: 'IC-1', title: 'Note 1', id: '1' }); - expect(rows[1]).toEqual({ done: '否', internal_code: 'IC-2', title: 'Note 2', id: '2' }); + expect(rows[0]).toEqual({ done: 'yes', internal_code: 'IC-1', title: 'Note 1', id: '1' }); + expect(rows[1]).toEqual({ done: 'no', internal_code: 'IC-2', title: 'Note 2', id: '2' }); }); it('JSON: a named key the read did not return is absent, never invented as null (the stripped companion)', async () => { diff --git a/packages/rest/src/import-template-route.test.ts b/packages/rest/src/import-template-route.test.ts index 4a02af2ad1b..4e24b1a8722 100644 --- a/packages/rest/src/import-template-route.test.ts +++ b/packages/rest/src/import-template-route.test.ts @@ -777,7 +777,10 @@ async function exportOnce(query: Record, security?: Record, timezone?: string, + booleanWords?: ExportBooleanWords, ): string { const lines: string[] = []; if (includeHeader) lines.push(fields.map(f => formatCsvCell(headerLabel(f, metaMap))).join(',')); for (const row of rows) { - lines.push(formatRowCells(row, fields, metaMap, timezone).map(formatCsvCell).join(',')); + lines.push(formatRowCells(row, fields, metaMap, timezone, booleanWords).map(formatCsvCell).join(',')); } return lines.join('\r\n') + (lines.length > 0 ? '\r\n' : ''); } @@ -5528,6 +5533,8 @@ export class RestServer { translateMetaItem: (...a) => this.translateMetaItem(...a), resolveMessageTranslator: (...a) => this.resolveMessageTranslator(...a), resolveSecurityService: (...a) => this.resolveSecurityService(...a), + resolveI18nService: (...a) => this.resolveI18nService(...a), + extractLocale: (...a) => this.extractLocale(...a), answerImportTemplate: (...a) => this.answerImportTemplate(...a), }; registerDataActionEndpoints.call(host, basePath); diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index bdd68d0879c..6351bf0792e 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1202,7 +1202,8 @@ describe('RestServer', () => { const route = getExportRoute(rest); const { res, chunks, headers } = makeRes(); - await route!.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, res); + // [#24945] A Chinese caller: the boolean cell follows the caller's language. + await route!.handler({ params: { object: 'task' }, query: { format: 'csv' }, headers: { 'accept-language': 'zh-CN' } } as any, res); expect(headers['Content-Type']).toBe('text/csv; charset=utf-8'); const lines = chunks.join('').split('\r\n'); @@ -1220,7 +1221,7 @@ describe('RestServer', () => { const route = getExportRoute(rest); const { res, chunks } = makeRes(); - await route!.handler({ params: { object: 'task' }, query: { format: 'csv', header: 'false' } } as any, res); + await route!.handler({ params: { object: 'task' }, query: { format: 'csv', header: 'false' }, headers: { 'accept-language': 'zh-CN' } } as any, res); const lines = chunks.join('').split('\r\n').filter(Boolean); // No label header — the first line is the data row. @@ -1254,7 +1255,7 @@ describe('RestServer', () => { const route = getExportRoute(rest); const { res, chunks, headers } = makeRes(); - await route!.handler({ params: { object: 'task' }, query: { format: 'json' } } as any, res); + await route!.handler({ params: { object: 'task' }, query: { format: 'json' }, headers: { 'accept-language': 'zh-CN' } } as any, res); expect(headers['Content-Type']).toBe('application/json; charset=utf-8'); const parsed = JSON.parse(chunks.join('')); @@ -1271,7 +1272,7 @@ describe('RestServer', () => { const route = getExportRoute(rest); const { res, getBuffer, headers } = makeBinRes(); - await route!.handler({ params: { object: 'task' }, query: { format: 'xlsx' } } as any, res); + await route!.handler({ params: { object: 'task' }, query: { format: 'xlsx' }, headers: { 'accept-language': 'zh-CN' } } as any, res); expect(headers['Content-Type']).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); expect(headers['Content-Disposition']).toMatch(/attachment; filename="task-\d{8}-\d{6}\.xlsx"; filename\*=UTF-8''/); From e3029bdb7e4c2bdfbf325f56af587368389a80fb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:05:45 +0000 Subject: [PATCH 3/4] chore(changeset): rest patch for export boolean cells in the caller's language Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .changeset/24945-export-boolean-locale.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/24945-export-boolean-locale.md diff --git a/.changeset/24945-export-boolean-locale.md b/.changeset/24945-export-boolean-locale.md new file mode 100644 index 00000000000..dd8e4ae3fda --- /dev/null +++ b/.changeset/24945-export-boolean-locale.md @@ -0,0 +1,14 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest): a boolean cell in the list export is written in the caller's language (#24945) + +Clause-②: no + +`GET /api/v1/data/:object/export` (Display → Export as CSV / XLSX / JSON) wrote every boolean or toggle cell as `是` or `否`, whatever the caller read. The header row of the same file follows the caller's locale, so an English caller got "Converted" and "Do Not Call" over Chinese cells. + +- **Now.** A boolean or toggle cell is written in the words the import template already uses for the caller's locale: `yes` / `no`, or `是` / `否` for a `zh*` locale. The locale is the one the header row is translated to: `Accept-Language`, then `?locale=`, then the deployment's default locale. The header and the cells always read one language. This applies to csv, xlsx and json. +- **No locale at all.** When the request names no locale and the deployment declares no default, the cells read `yes` / `no`, the same words the import template writes then. +- **Re-import.** A file exported in either language re-imports its boolean column: the importer reads `yes` / `no` and `是` / `否`. +- **Unchanged.** No query parameter, header, column set or payload shape moves. Only the text of a boolean cell changes, and a Chinese caller's file is byte-for-byte what it was. From 3d3bafba2842e08acbfc9de0409d368a1e30ba9e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:34:09 +0000 Subject: [PATCH 4/4] test(rest): type the engine query options in the boolean-locale pins check:query-options-erasure counts an options bag cast to any; the re-import pins pass a typed orderBy instead. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- packages/rest/src/export-boolean-locale.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rest/src/export-boolean-locale.test.ts b/packages/rest/src/export-boolean-locale.test.ts index a343a1879ea..c6c93879895 100644 --- a/packages/rest/src/export-boolean-locale.test.ts +++ b/packages/rest/src/export-boolean-locale.test.ts @@ -231,7 +231,7 @@ describe('export → import: each language re-imports its boolean column', () => body: { format: 'csv', csv, mapping: MAPPINGS[lang] }, } as any, res); expect(res._json).toMatchObject({ total: 2, ok: 2, errors: 0, created: 2 }); - const stored = await target.engine.find('lead', { orderBy: [{ field: 'id', order: 'asc' }] } as any); + const stored = await target.engine.find('lead', { orderBy: [{ field: 'id', order: 'asc' }] }); expect(stored.map((r: any) => [r.id, r.is_converted, r.do_not_call])) .toEqual([['1', true, false], ['2', false, true]]); }); @@ -248,7 +248,7 @@ describe('export → import: each language re-imports its boolean column', () => body: { format: 'xlsx', xlsxBase64, mapping: MAPPINGS[lang] }, } as any, res); expect(res._json).toMatchObject({ total: 2, ok: 2, errors: 0, created: 2 }); - const stored = await target.engine.find('lead', { orderBy: [{ field: 'id', order: 'asc' }] } as any); + const stored = await target.engine.find('lead', { orderBy: [{ field: 'id', order: 'asc' }] }); expect(stored.map((r: any) => [r.id, r.is_converted, r.do_not_call])) .toEqual([['1', true, false], ['2', false, true]]); });