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. 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-boolean-locale.test.ts b/packages/rest/src/export-boolean-locale.test.ts new file mode 100644 index 00000000000..c6c93879895 --- /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' }] }); + 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' }] }); + expect(stored.map((r: any) => [r.id, r.is_converted, r.do_not_call])) + .toEqual([['1', true, false], ['2', false, true]]); + }); + } +}); 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''/);