From 051c32ab4b416883281eae1bb66d8b1856e067c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 08:31:55 +0000 Subject: [PATCH 1/2] feat(i18n): ship the platform's 11 language tags, resolved by script Rename zh -> zh-Hans and pt -> pt-BR, add a zh-Hant bundle converted from zh-Hans (opencc-js s2twp with the www.objectos.ai phrase overrides), and replace the Console's exact-then-base matcher with resolveLanguageTag: Hant/Hans script first, TW/HK/MO -> zh-Hant, other zh -> zh-Hans, pt-* -> pt-BR, else the primary subtag. zh-Hant gets its own iOS permission text. Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- .../components/ConnectionSwitcher.test.tsx | 2 +- __tests__/components/RtlLayout.test.tsx | 4 +- .../components/ServerConfigScreen.test.tsx | 2 +- .../components/SignInExcursionBar.test.tsx | 2 +- __tests__/lib/app-config.test.ts | 23 ++- __tests__/lib/i18n.test.ts | 128 +++++++++----- app.config.ts | 30 ++-- lib/i18n.ts | 137 +++++++++------ locales/{pt.json => pt-BR.json} | 0 locales/{zh.json => zh-Hans.json} | 0 locales/zh-Hant.json | 157 ++++++++++++++++++ 11 files changed, 372 insertions(+), 113 deletions(-) rename locales/{pt.json => pt-BR.json} (100%) rename locales/{zh.json => zh-Hans.json} (100%) create mode 100644 locales/zh-Hant.json diff --git a/__tests__/components/ConnectionSwitcher.test.tsx b/__tests__/components/ConnectionSwitcher.test.tsx index 5f77248..9bfd385 100644 --- a/__tests__/components/ConnectionSwitcher.test.tsx +++ b/__tests__/components/ConnectionSwitcher.test.tsx @@ -217,7 +217,7 @@ describe("ConnectionSwitcher", () => { ); await act(async () => { - await i18n.changeLanguage("zh"); + await i18n.changeLanguage("zh-Hans"); }); expect(screen.queryByText("添加组织或公司服务器")).toBeNull(); }); diff --git a/__tests__/components/RtlLayout.test.tsx b/__tests__/components/RtlLayout.test.tsx index 6a47224..c4d6da2 100644 --- a/__tests__/components/RtlLayout.test.tsx +++ b/__tests__/components/RtlLayout.test.tsx @@ -68,9 +68,9 @@ afterEach(async () => { }); describe("layoutDirection", () => { - it("is rtl for Arabic and ltr for the other nine shell languages", () => { + it("is rtl for Arabic and ltr for the other ten shell languages", () => { expect(layoutDirection("ar")).toBe("rtl"); - for (const code of ["en", "zh", "ja", "ko", "de", "fr", "es", "pt", "ru"]) { + for (const code of ["en", "zh-Hans", "zh-Hant", "es", "fr", "pt-BR", "ru", "de", "ja", "ko"]) { expect(layoutDirection(code)).toBe("ltr"); } }); diff --git a/__tests__/components/ServerConfigScreen.test.tsx b/__tests__/components/ServerConfigScreen.test.tsx index 5246e08..962030d 100644 --- a/__tests__/components/ServerConfigScreen.test.tsx +++ b/__tests__/components/ServerConfigScreen.test.tsx @@ -381,7 +381,7 @@ describe("ServerConfigScreen — managed deployment (MDM)", () => { it("shows 「请联系 IT 管理员」 in Chinese", async () => { const i18n = require("~/lib/i18n").default; await act(async () => { - await i18n.changeLanguage("zh"); + await i18n.changeLanguage("zh-Hans"); }); try { managedBy(LOCKED); diff --git a/__tests__/components/SignInExcursionBar.test.tsx b/__tests__/components/SignInExcursionBar.test.tsx index 4db0eda..5c8fe9c 100644 --- a/__tests__/components/SignInExcursionBar.test.tsx +++ b/__tests__/components/SignInExcursionBar.test.tsx @@ -40,7 +40,7 @@ describe("SignInExcursionBar", () => { it("reads 正在外部页面登录 · and 取消 in Chinese", async () => { await act(async () => { - await i18n.changeLanguage("zh"); + await i18n.changeLanguage("zh-Hans"); }); render( diff --git a/__tests__/lib/app-config.test.ts b/__tests__/lib/app-config.test.ts index 1cff360..1e46f47 100644 --- a/__tests__/lib/app-config.test.ts +++ b/__tests__/lib/app-config.test.ts @@ -5,7 +5,9 @@ */ import appConfig from "~/app.config"; import en from "~/locales/en.json"; -import zh from "~/locales/zh.json"; +import ptBR from "~/locales/pt-BR.json"; +import zhHans from "~/locales/zh-Hans.json"; +import zhHant from "~/locales/zh-Hant.json"; type Plugin = string | [string, Record]; @@ -26,11 +28,20 @@ describe("app.config permissions", () => { expect(ios.NSFaceIDUsageDescription).not.toBe(en.permissions.faceId); } } - expect(locales["zh-Hant"].ios).toEqual({ - NSCameraUsageDescription: zh.permissions.camera, - NSPhotoLibraryUsageDescription: zh.permissions.photos, - NSFaceIDUsageDescription: zh.permissions.faceId, - }); + // Each Chinese script carries its own bundle's text, never the other's. + for (const [lproj, bundle] of [ + ["zh-Hans", zhHans], + ["zh-Hant", zhHant], + ] as const) { + expect(locales[lproj].ios).toEqual({ + NSCameraUsageDescription: bundle.permissions.camera, + NSPhotoLibraryUsageDescription: bundle.permissions.photos, + NSFaceIDUsageDescription: bundle.permissions.faceId, + }); + } + expect(locales["zh-Hant"].ios.NSCameraUsageDescription).not.toBe(zhHans.permissions.camera); + // Portuguese is written as the unqualified `pt`, so every variant falls back to the pt-BR text. + expect(locales.pt.ios.NSCameraUsageDescription).toBe(ptBR.permissions.camera); }); it("gives the app lock its Face ID text, and blocks only the screen-capture module's unused permissions", () => { diff --git a/__tests__/lib/i18n.test.ts b/__tests__/lib/i18n.test.ts index 9cae7a6..9112852 100644 --- a/__tests__/lib/i18n.test.ts +++ b/__tests__/lib/i18n.test.ts @@ -7,18 +7,30 @@ import i18n, { followDeviceLanguage, followDeviceLanguageOnForeground, isRTL, - matchLanguageTag, resolveLanguage, + resolveLanguageTag, SUPPORTED_LANGUAGES, RTL_LANGUAGES, } from "~/lib/i18n"; /** - * The Console UI's ten built-in catalogue codes, in its order (objectstack - * `ui/packages/i18n/src/locales/registry.ts`, `BUILT_IN_LANGUAGE_CODES`). - * The shell must match this list one-to-one. + * The platform's one list of 11 language tags, in the table's order + * (objectstack-ai/objectstack#29428, ruling F of #29349). The shell must match + * this list one-to-one. */ -const CONSOLE_LANGUAGE_CODES = ["en", "zh", "ja", "ko", "de", "fr", "es", "pt", "ru", "ar"]; +const PLATFORM_LANGUAGE_TAGS = [ + "en", + "zh-Hans", + "zh-Hant", + "es", + "ar", + "fr", + "pt-BR", + "ru", + "de", + "ja", + "ko", +]; // jest.setup.ts mocks this module without a `default`, which is what // `react-native`'s `AppState` getter reads; give it one for this file. @@ -57,16 +69,20 @@ describe("i18n configuration", () => { }); it("translates through the active bundle", async () => { - await i18n.changeLanguage("zh"); + await i18n.changeLanguage("zh-Hans"); expect(i18n.t("common.ok")).toBe("确定"); + await i18n.changeLanguage("zh-Hant"); + expect(i18n.t("common.ok")).toBe("確定"); + await i18n.changeLanguage("pt-BR"); + expect(i18n.t("common.cancel")).toBe("Cancelar"); await i18n.changeLanguage("ja"); expect(i18n.t("serverConfirm.signIn.emailPassword")).toBe("メールアドレスとパスワード"); }); }); describe("SUPPORTED_LANGUAGES", () => { - it("is exactly the Console UI's ten locale codes, in the Console's order", () => { - expect(SUPPORTED_LANGUAGES.map((l) => l.code)).toEqual(CONSOLE_LANGUAGE_CODES); + it("is exactly the platform's 11 language tags, in the table's order", () => { + expect(SUPPORTED_LANGUAGES.map((l) => l.code)).toEqual(PLATFORM_LANGUAGE_TAGS); }); it("names every language in its own language", () => { @@ -78,7 +94,7 @@ describe("SUPPORTED_LANGUAGES", () => { .filter((f) => f.endsWith(".json")) .map((f) => f.replace(/\.json$/, "")) .sort(); - expect(bundles).toEqual([...CONSOLE_LANGUAGE_CODES].sort()); + expect(bundles).toEqual([...PLATFORM_LANGUAGE_TAGS].sort()); }); }); @@ -97,7 +113,7 @@ describe("locale bundles", () => { const en = flatten(read("en")); - it.each(CONSOLE_LANGUAGE_CODES.filter((c) => c !== "en"))( + it.each(PLATFORM_LANGUAGE_TAGS.filter((c) => c !== "en"))( "%s carries every native string key en has, and no other", (code) => { const bundle = flatten(read(code)); @@ -107,7 +123,7 @@ describe("locale bundles", () => { } ); - it.each(CONSOLE_LANGUAGE_CODES)("%s has no empty string and keeps every placeholder", (code) => { + it.each(PLATFORM_LANGUAGE_TAGS)("%s has no empty string and keeps every placeholder", (code) => { const bundle = flatten(read(code)); for (const [key, value] of Object.entries(bundle)) { expect({ key, empty: value.trim() === "" }).toEqual({ key, empty: false }); @@ -119,58 +135,88 @@ describe("locale bundles", () => { }); }); -describe("matchLanguageTag — the Console's exact-then-base rule", () => { - // The Console's own pins (region-locale-fallback-11326.test.tsx), ported. - it("prefers an exact match over the base language, and falls back to the base", () => { - const has = (code: string) => code === "pt" || code === "pt-BR"; - expect(matchLanguageTag("pt-BR", has)).toBe("pt-BR"); - expect(matchLanguageTag("pt-PT", has)).toBe("pt"); - expect(matchLanguageTag("pt", has)).toBe("pt"); - expect(matchLanguageTag("xx-YY", has)).toBeNull(); +describe("resolveLanguageTag — the platform's alias rule, by script", () => { + // objectstack-ai/objectstack#29428, phase 1: each read alias it declares. + it.each([ + ["zh", "zh-Hans"], + ["zh-CN", "zh-Hans"], + ["zh-SG", "zh-Hans"], + ["zh-TW", "zh-Hant"], + ["zh-HK", "zh-Hant"], + ["zh-MO", "zh-Hant"], + ["ja-JP", "ja"], + ["es-ES", "es"], + ["pt", "pt-BR"], + ["en-US", "en"], + ])("resolves the platform alias %s to %s", (tag, code) => { + expect(resolveLanguageTag(tag)).toBe(code); + }); + + it("lets an explicit script subtag decide over the region", () => { + expect(resolveLanguageTag("zh-Hant-TW")).toBe("zh-Hant"); + expect(resolveLanguageTag("zh-Hant-CN")).toBe("zh-Hant"); + expect(resolveLanguageTag("zh-Hans-HK")).toBe("zh-Hans"); + expect(resolveLanguageTag("zh-Hans-MO")).toBe("zh-Hans"); + }); + + it("compares subtags case-insensitively, as BCP 47 defines them", () => { + expect(resolveLanguageTag("ZH-hant")).toBe("zh-Hant"); + expect(resolveLanguageTag("zh-tw")).toBe("zh-Hant"); + expect(resolveLanguageTag("PT-pt")).toBe("pt-BR"); + expect(resolveLanguageTag("EN")).toBe("en"); + }); + + it("never reads an extension value as the region", () => { + expect(resolveLanguageTag("zh-u-rg-twzzzz")).toBe("zh-Hans"); + expect(resolveLanguageTag("zh-CN-u-nu-hanidec")).toBe("zh-Hans"); + expect(resolveLanguageTag("zh-x-tw")).toBe("zh-Hans"); + }); + + it("answers null for a language with no bundle", () => { + expect(resolveLanguageTag("it-IT")).toBeNull(); + expect(resolveLanguageTag("yue-HK")).toBeNull(); + expect(resolveLanguageTag("")).toBeNull(); }); }); describe("detectDeviceLanguage", () => { - it.each(CONSOLE_LANGUAGE_CODES)("resolves the bare code %s to itself", (code) => { + it.each(PLATFORM_LANGUAGE_TAGS)("resolves the table tag %s to itself", (code) => { expect(detectDeviceLanguage(tags(code))).toBe(code); }); it.each([ ["en-US", "en"], - ["zh-CN", "zh"], ["ja-JP", "ja"], ["ko-KR", "ko"], ["de-DE", "de"], ["fr-FR", "fr"], ["es-ES", "es"], - ["pt-PT", "pt"], + ["es-MX", "es"], ["ru-RU", "ru"], ["ar-SA", "ar"], - ])("resolves the region tag %s to %s", (tag, code) => { + ])("resolves the region tag %s to its primary subtag %s", (tag, code) => { expect(detectDeviceLanguage(tags(tag))).toBe(code); }); - it.each([ - ["zh-Hans-CN", "zh"], - ["pt-BR", "pt"], - ["es-MX", "es"], - ])("resolves %s to %s", (tag, code) => { - expect(detectDeviceLanguage(tags(tag))).toBe(code); + it.each(["zh-Hans-CN", "zh-CN", "zh-SG", "zh"])("resolves Simplified Chinese %s to zh-Hans", (tag) => { + expect(detectDeviceLanguage(tags(tag))).toBe("zh-Hans"); }); - // The Console has no Traditional Chinese catalogue, and its rule resolves a - // `zh-*` tag to its base `zh` (registry.ts `matchLanguageTag`). The shell - // follows that rule rather than inventing a different one. - it.each(["zh-TW", "zh-HK", "zh-Hant", "zh-Hant-TW", "zh-Hant-HK"])( - "resolves Traditional Chinese %s to zh, as the Console does", + it.each(["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-TW", "zh-Hant-HK"])( + "resolves Traditional Chinese %s to zh-Hant", (tag) => { - expect(detectDeviceLanguage(tags(tag))).toBe("zh"); + expect(detectDeviceLanguage(tags(tag))).toBe("zh-Hant"); } ); + it.each(["pt-PT", "pt-BR", "pt-AO", "pt"])("resolves Portuguese %s to pt-BR", (tag) => { + expect(detectDeviceLanguage(tags(tag))).toBe("pt-BR"); + }); + it("falls through an unsupported first language to the next supported one", () => { expect(detectDeviceLanguage(tags("he-IL", "ja-JP", "en-US"))).toBe("ja"); - expect(detectDeviceLanguage(tags("tlh", "it-IT", "pt-BR"))).toBe("pt"); + expect(detectDeviceLanguage(tags("tlh", "it-IT", "pt-PT"))).toBe("pt-BR"); + expect(detectDeviceLanguage(tags("yue-HK", "zh-HK", "en-US"))).toBe("zh-Hant"); }); it("falls back to en when no preferred language is supported", () => { @@ -186,6 +232,14 @@ describe("detectDeviceLanguage", () => { expect(detectDeviceLanguage([{ languageTag: "", languageCode: "ko" }])).toBe("ko"); }); + it("keeps the script and region of a locale that has no languageTag", () => { + expect( + detectDeviceLanguage([{ languageCode: "zh", languageScriptCode: "Hant", regionCode: "TW" }]) + ).toBe("zh-Hant"); + expect(detectDeviceLanguage([{ languageCode: "zh", regionCode: "HK" }])).toBe("zh-Hant"); + expect(detectDeviceLanguage([{ languageCode: "zh", regionCode: "CN" }])).toBe("zh-Hans"); + }); + it("reads the phone's languages when none are passed", () => { const spy = jest .spyOn(Localization, "getLocales") @@ -255,7 +309,7 @@ describe("isRTL", () => { }); it("returns false for every other shell language", () => { - for (const code of CONSOLE_LANGUAGE_CODES.filter((c) => c !== "ar")) { + for (const code of PLATFORM_LANGUAGE_TAGS.filter((c) => c !== "ar")) { expect(isRTL(code)).toBe(false); } }); diff --git a/app.config.ts b/app.config.ts index 28b590b..50f31b5 100644 --- a/app.config.ts +++ b/app.config.ts @@ -6,34 +6,36 @@ import es from "./locales/es.json"; import fr from "./locales/fr.json"; import ja from "./locales/ja.json"; import ko from "./locales/ko.json"; -import pt from "./locales/pt.json"; +import ptBR from "./locales/pt-BR.json"; import ru from "./locales/ru.json"; -import zh from "./locales/zh.json"; +import zhHans from "./locales/zh-Hans.json"; +import zhHant from "./locales/zh-Hant.json"; // System permission prompts, localized from the app's own bundles (keys // `permissions.camera`, `permissions.photos` and `permissions.faceId`) so one // file holds each language's copy. iOS reads them // from `/InfoPlist.strings`; Android's camera prompt is the system's own // text. Keys are iOS localization names, written verbatim as `.lproj`: -// one per shell language (lib/i18n.ts). `zh` is Simplified Chinese, so it is -// `zh-Hans`; Traditional Chinese (`zh-Hant`) gets the same `zh` copy, because -// the shell resolves `zh-TW`/`zh-HK` to `zh` as the Console does. `pt` and `es` -// stay unqualified so every regional variant falls back to them. +// one per shell language (lib/i18n.ts), each with its own bundle's text — +// Simplified Chinese is `zh-Hans` and Traditional Chinese is `zh-Hant`. The +// Portuguese bundle is `pt-BR`, written as the unqualified `pt` so every +// Portuguese variant falls back to it, as the shell's own `pt-*` → `pt-BR` +// rule does; `es` stays unqualified for the same reason. const PERMISSION_LOCALES: Record< string, { permissions: { camera: string; photos: string; faceId: string } } > = { en, - "zh-Hans": zh, - "zh-Hant": zh, - ja, - ko, - de, - fr, + "zh-Hans": zhHans, + "zh-Hant": zhHant, es, - pt, - ru, ar, + fr, + pt: ptBR, + ru, + de, + ja, + ko, }; // The EAS project this app builds and publishes under: `@objectstack/objectos`. diff --git a/lib/i18n.ts b/lib/i18n.ts index 8613653..3da779e 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -2,11 +2,10 @@ * Internationalization configuration. * * Integrates i18next + react-i18next + expo-localization. Ships the shell's own - * strings (server picker, confirm screen, loading, offline page) in the same - * ten locales, under the same short codes, as the Console UI - * (objectstack `ui/packages/i18n/src/locales/registry.ts`, - * `BUILT_IN_LANGUAGE_CODES`), so a phone language lands on the same locale on - * both sides. Everything inside the Console is translated by the Console. + * strings (server picker, confirm screen, loading, offline page) in the + * platform's one list of 11 languages, keyed by the platform's tags + * (objectstack-ai/objectstack#29428, ruling F of #29349). Everything inside the + * Console is translated by the Console. * * The phone setting is the only source: there is no in-app language picker for * the native screens, and the shell follows a phone-language change the next @@ -19,35 +18,36 @@ import { getLocales, type Locale } from "expo-localization"; import { AppState, type AppStateStatus } from "react-native"; import en from "~/locales/en.json"; -import zh from "~/locales/zh.json"; -import ja from "~/locales/ja.json"; -import ko from "~/locales/ko.json"; -import de from "~/locales/de.json"; -import fr from "~/locales/fr.json"; +import zhHans from "~/locales/zh-Hans.json"; +import zhHant from "~/locales/zh-Hant.json"; import es from "~/locales/es.json"; -import pt from "~/locales/pt.json"; -import ru from "~/locales/ru.json"; import ar from "~/locales/ar.json"; +import fr from "~/locales/fr.json"; +import ptBR from "~/locales/pt-BR.json"; +import ru from "~/locales/ru.json"; +import de from "~/locales/de.json"; +import ja from "~/locales/ja.json"; +import ko from "~/locales/ko.json"; /** Languages that use right-to-left layout — the Console's list (`locales/rtl.ts`). */ export const RTL_LANGUAGES = new Set(["ar", "he", "fa", "ur"]); /** - * All bundled languages with their native display names: the Console's ten - * codes in the Console's order, named as the Console's language menu names them - * (objectstack `ui/packages/app-shell/src/layout/LocaleSwitcher.tsx`). + * All bundled languages with their native display names: the platform's 11 + * tags, in the platform table's order (objectstack-ai/objectstack#29428). */ export const SUPPORTED_LANGUAGES = [ { code: "en", label: "English" }, - { code: "zh", label: "中文" }, - { code: "ja", label: "日本語" }, - { code: "ko", label: "한국어" }, - { code: "de", label: "Deutsch" }, - { code: "fr", label: "Français" }, + { code: "zh-Hans", label: "简体中文" }, + { code: "zh-Hant", label: "繁體中文" }, { code: "es", label: "Español" }, - { code: "pt", label: "Português" }, - { code: "ru", label: "Русский" }, { code: "ar", label: "العربية" }, + { code: "fr", label: "Français" }, + { code: "pt-BR", label: "Português (Brasil)" }, + { code: "ru", label: "Русский" }, + { code: "de", label: "Deutsch" }, + { code: "ja", label: "日本語" }, + { code: "ko", label: "한국어" }, ] as const; export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"]; @@ -55,46 +55,80 @@ export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"]; /** The language used when no preferred phone language is supported. */ export const DEFAULT_LANGUAGE: SupportedLanguage = "en"; -const SUPPORTED_CODES: ReadonlySet = new Set(SUPPORTED_LANGUAGES.map((l) => l.code)); +/** Supported languages whose tag is a bare primary subtag (`en`, `ja`, …), lower-cased. */ +const PRIMARY_ONLY: ReadonlySet = new Set( + SUPPORTED_LANGUAGES.map((l) => l.code).filter((code) => !code.includes("-")) +); -function isSupportedLanguage(code: string): code is SupportedLanguage { - return SUPPORTED_CODES.has(code); -} +/** Regions whose Chinese is written in Traditional script. */ +const HANT_REGIONS: ReadonlySet = new Set(["tw", "hk", "mo"]); /** - * Exact-then-base language-tag matching: `tag` itself when `has(tag)`, else its - * base language (`zh-CN` → `zh`) when `has(base)`, else `null`. + * Resolve one BCP 47 language tag to a bundled language, by script, or `null` + * when the tag names no bundled language. The platform's alias rule + * (objectstack-ai/objectstack#29428): * - * A port of the Console's `matchLanguageTag` (objectstack - * `ui/packages/i18n/src/locales/registry.ts`), kept byte-for-byte in behaviour - * so a tag resolves to the same locale on both sides. Consequences that follow - * from the Console's rule, not from a choice made here: + * - Chinese (`zh`): an explicit script subtag decides — `Hant` → `zh-Hant`, + * `Hans` → `zh-Hans`. Without one, the regions `TW`, `HK` and `MO` → + * `zh-Hant`, and every other `zh` tag (bare `zh`, `zh-CN`, `zh-SG`, …) → + * `zh-Hans`. + * - Portuguese: every `pt` tag (bare `pt`, `pt-PT`, `pt-BR`, …) → `pt-BR`. + * - Any other tag → its primary subtag when that is bundled (`en-US` → `en`, + * `es-MX` → `es`), else `null`. * - * - Traditional Chinese (`zh-Hant`, `zh-TW`, `zh-HK`, `zh-Hant-TW`) has no - * catalogue of its own, so it resolves to `zh`, as in the Console. - * - The base is the text before the first `-`; case is not folded. + * Subtags compare case-insensitively, as BCP 47 defines them. Only the + * language, script and region subtags are read: scanning stops at the first + * singleton (`-u-`, `-x-`, …), so an extension value never reads as a region. */ -export function matchLanguageTag(tag: string, has: (code: string) => boolean): string | null { - if (has(tag)) return tag; - const base = tag.split("-")[0]; - if (base && base !== tag && has(base)) return base; - return null; +export function resolveLanguageTag(tag: string): SupportedLanguage | null { + const subtags = tag.toLowerCase().split("-"); + const primary = subtags[0]; + if (!primary) return null; + + if (primary === "zh") { + let script: string | undefined; + let region: string | undefined; + for (const subtag of subtags.slice(1)) { + if (subtag.length === 1) break; + if (!script && !region && /^[a-z]{4}$/.test(subtag)) script = subtag; + else if (!region && /^([a-z]{2}|\d{3})$/.test(subtag)) region = subtag; + } + if (script === "hant") return "zh-Hant"; + if (script === "hans") return "zh-Hans"; + return region && HANT_REGIONS.has(region) ? "zh-Hant" : "zh-Hans"; + } + if (primary === "pt") return "pt-BR"; + return PRIMARY_ONLY.has(primary) ? (primary as SupportedLanguage) : null; } /** * Pick the shell language from an ordered list of preferred language tags: the - * first tag that {@link matchLanguageTag} resolves to a bundled language, else + * first tag that {@link resolveLanguageTag} resolves to a bundled language, else * {@link DEFAULT_LANGUAGE}. */ export function resolveLanguage(tags: readonly (string | null | undefined)[]): SupportedLanguage { for (const tag of tags) { if (!tag) continue; - const match = matchLanguageTag(tag, isSupportedLanguage); - if (match) return match as SupportedLanguage; + const match = resolveLanguageTag(tag); + if (match) return match; } return DEFAULT_LANGUAGE; } +/** + * One phone locale as a language tag: its `languageTag`, else a tag built from + * its language, script and region codes, so a locale reported without a tag + * keeps its script (`zh` + `Hant` + `TW` → `zh-Hant-TW`). + */ +function localeTag(locale: Partial | null | undefined): string | undefined { + if (!locale) return undefined; + if (locale.languageTag) return locale.languageTag; + if (!locale.languageCode) return undefined; + return [locale.languageCode, locale.languageScriptCode, locale.regionCode] + .filter(Boolean) + .join("-"); +} + /** * Detect the shell language from the phone's ordered preferred languages * (`expo-localization`'s `getLocales()`, in the order the user set them). @@ -103,7 +137,7 @@ export function resolveLanguage(tags: readonly (string | null | undefined)[]): S export function detectDeviceLanguage(locales?: readonly Partial[]): SupportedLanguage { try { const list = locales ?? getLocales(); - return resolveLanguage((list ?? []).map((l) => l?.languageTag || l?.languageCode)); + return resolveLanguage((list ?? []).map(localeTag)); } catch { return DEFAULT_LANGUAGE; } @@ -117,15 +151,16 @@ export function isRTL(lang?: string): boolean { const resources = { en: { translation: en }, - zh: { translation: zh }, - ja: { translation: ja }, - ko: { translation: ko }, - de: { translation: de }, - fr: { translation: fr }, + "zh-Hans": { translation: zhHans }, + "zh-Hant": { translation: zhHant }, es: { translation: es }, - pt: { translation: pt }, - ru: { translation: ru }, ar: { translation: ar }, + fr: { translation: fr }, + "pt-BR": { translation: ptBR }, + ru: { translation: ru }, + de: { translation: de }, + ja: { translation: ja }, + ko: { translation: ko }, } as const satisfies Record; i18n.use(initReactI18next).init({ diff --git a/locales/pt.json b/locales/pt-BR.json similarity index 100% rename from locales/pt.json rename to locales/pt-BR.json diff --git a/locales/zh.json b/locales/zh-Hans.json similarity index 100% rename from locales/zh.json rename to locales/zh-Hans.json diff --git a/locales/zh-Hant.json b/locales/zh-Hant.json new file mode 100644 index 0000000..4012c07 --- /dev/null +++ b/locales/zh-Hant.json @@ -0,0 +1,157 @@ +{ + "common": { + "ok": "確定", + "cancel": "取消", + "retry": "重試", + "error": "錯誤", + "close": "關閉" + }, + "errorBoundary": { + "title": "出錯了", + "message": "發生了意外錯誤。", + "retry": "重試" + }, + "serverConfig": { + "title": "連線伺服器", + "subtitle": "輸入你的 ObjectOS 伺服器地址。", + "label": "伺服器地址", + "hint": "例如:https://erp.example.com", + "connect": "繼續", + "scan": "掃描二維碼", + "backTo": "返回 {{server}}", + "where": { + "title": "在哪裡找到地址?", + "step1": "在電腦瀏覽器中開啟 ObjectOS 並登入。", + "step2": "複製瀏覽器位址列中的地址。可以直接貼上整個連結,應用只保留伺服器部分。", + "step3": "仍不確定?請聯絡管理員。" + }, + "vpnHint": "公司內網的伺服器可能需要手機先連線公司 VPN。", + "error": { + "empty": "請輸入伺服器地址。", + "invalid": "這看起來不是有效的伺服器地址。", + "insecure": "請使用 https:// 地址,http 僅允許用於本地伺服器。", + "unreachable": "無法連線伺服器。請檢查地址和網路,公司內網的伺服器可能需要先連線 VPN。", + "tls": "證書無效:無法驗證伺服器的安全證書,連線不安全。請聯絡管理員修復。", + "timeout": "伺服器響應超時。請檢查網路或 VPN 後重試。", + "notObjectOS": "這不是 ObjectOS 伺服器。請使用在瀏覽器中開啟 ObjectOS 的地址。", + "serverError": "伺服器返回錯誤(HTTP {{status}})。請稍後重試或聯絡管理員。", + "unknown": "出現問題,請重試。" + }, + "managed": { + "title": "由你的組織配置", + "checking": "正在檢查組織配置的伺服器…", + "contactIt": "請聯絡 IT 管理員。", + "retry": "重試", + "edit": "輸入其他地址", + "locked": "此 App 的伺服器由你的組織管理,無法在這裡更改。" + } + }, + "serverConfirm": { + "title": "這是你的伺服器嗎?", + "secure": "安全連線(HTTPS)", + "signInLabel": "登入方式", + "signIn": { + "sso": "單點登入(SSO)", + "emailPassword": "郵箱和密碼" + }, + "versionLabel": "平臺版本", + "confirm": "連線並登入", + "reject": "不是這臺伺服器" + }, + "shell": { + "loading": "載入中…", + "offlineTitle": "網路已斷開", + "offlineMessage": "請檢查網路連線。恢復聯網後頁面會自動重新載入。", + "serverErrorTitle": "伺服器無響應", + "serverErrorMessage": "伺服器返回錯誤 {{code}},請稍後重試。", + "policyTitle": "無法在應用內顯示此頁面", + "policyMessage": "伺服器的頁面一直跳轉到應用不開啟的地址。請重試,或切換組織。", + "retry": "重試" + }, + "excursion": { + "signingIn": "正在外部頁面登入 · {{server}}", + "secure": "安全連線", + "cancel": "取消", + "endedTitle": "登入未完成", + "redirects": "登入頁面重定向次數過多。請重試,或聯絡管理員。", + "timeout": "登入用時超過 {{minutes}} 分鐘。請重新開始。", + "insecure": "登入頁面試圖通過不安全的連線(http)繼續,已被停止。", + "unsupported": "應用暫不支援此登入方式。請選擇其他登入方式。", + "dismiss": "關閉" + }, + "switcher": { + "title": "切換組織", + "add": "新增組織或公司伺服器", + "footnote": "每個組織單獨儲存登入狀態。左滑可移除,並退出該組織的登入。", + "lockedFootnote": "每個組織單獨儲存登入狀態。{{label}} 由你的組織配置,無法移除。", + "appSettings": "App 設定", + "kind": { + "cloud": "Cloud", + "server": "私有" + }, + "remove": "移除", + "removeNamed": "移除{{label}}" + }, + "scanner": { + "hint": "將二維碼放入框內", + "codeHint": "將條碼或二維碼放入框內", + "connectNote": "只包含伺服器地址,不含任何賬號資訊", + "manual": "手動輸入地址", + "notConnectCode": "不是連線二維碼", + "unreadable": "無法讀取此碼", + "close": "關閉", + "torchOn": "開啟手電筒", + "torchOff": "關閉手電筒", + "permission": { + "title": "需要相機權限", + "body": "允許使用相機以掃描二維碼。", + "blocked": "ObjectOS 的相機權限已關閉。請在設定中開啟,或手動輸入地址。", + "allow": "允許使用相機", + "settings": "開啟設定", + "codeBody": "允許使用相機以掃描條碼或二維碼。", + "codeBlocked": "ObjectOS 的相機權限已關閉。請在設定中開啟後再掃描。" + } + }, + "appLock": { + "entry": "應用鎖", + "on": "已開啟", + "off": "已關閉", + "prompt": { + "unlock": "解鎖 ObjectOS", + "change": "更改應用鎖" + }, + "overlay": { + "title": "ObjectOS 已鎖定", + "unlock": "解鎖", + "failed": "無法確認是你本人,請重試。", + "noDeviceLock": "請先在手機設定中設定鎖屏,才能解鎖 ObjectOS。" + }, + "settings": { + "title": "應用鎖", + "description": "回到 ObjectOS 時,需要通過面容 ID、觸控 ID、指紋或手機密碼才能檢視。", + "toggle": "鎖定應用", + "timeoutTitle": "鎖定時間", + "timeout": { + "0": "立即", + "60": "1 分鐘後", + "300": "5 分鐘後", + "900": "15 分鐘後", + "3600": "1 小時後" + }, + "authNote": "更改應用鎖需要驗證面容 ID、觸控 ID、指紋或手機密碼。", + "unavailable": "請先在手機設定中設定鎖屏(密碼、面容 ID 或指紋),才能使用應用鎖。", + "notChanged": "未更改:無法確認是你本人。", + "saveFailed": "設定未能儲存,請重試。", + "back": "返回", + "privacy": { + "ios": "應用鎖開啟時,多工介面中的 ObjectOS 會顯示為模糊。", + "android": "應用鎖開啟時,最近任務中的 ObjectOS 顯示為空白,並且禁止截圖和錄屏。" + } + } + }, + "permissions": { + "camera": "ObjectOS 使用相機掃描二維碼和條碼(例如用於連線伺服器的二維碼),以及拍攝要在應用中附加的照片。", + "photos": "ObjectOS 開啟你的照片相簿,以便你選擇要在應用中附加的照片。", + "faceId": "應用鎖開啟時,ObjectOS 使用面容 ID 解鎖應用。" + } +} From a4a903a6e41e815fd919244044414f8d3b6dae68 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 08:33:25 +0000 Subject: [PATCH 2/2] docs(i18n): name the platform's 11 languages and the zh-Hant source HYBRID-SHELL and connection-flow say 11 and name the table; the device check and the contributing note stop naming zh.json and stop saying zh-TW shows Simplified Chinese. Changeset for the locale move. Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- .changeset/182-eleven-locales.md | 21 ++++++++++++++++++ docs/CONTRIBUTING.md | 3 ++- docs/DEVICE-TESTING.md | 2 +- docs/HYBRID-SHELL.md | 38 ++++++++++++++++++-------------- docs/design/connection-flow.md | 7 +++--- 5 files changed, 49 insertions(+), 22 deletions(-) create mode 100644 .changeset/182-eleven-locales.md diff --git a/.changeset/182-eleven-locales.md b/.changeset/182-eleven-locales.md new file mode 100644 index 0000000..8bb0258 --- /dev/null +++ b/.changeset/182-eleven-locales.md @@ -0,0 +1,21 @@ +--- +"mobile": patch +--- + +Clause-②: no +Metadata-protocol: none + +The native screens now ship in the platform's one list of 11 languages, keyed +by the platform table's tags: en, zh-Hans, zh-Hant, es, ar, fr, pt-BR, ru, de, +ja, ko. Traditional Chinese is new: a phone set to 繁體中文 (`zh-Hant-*`, or +`zh-TW` / `zh-HK` / `zh-MO`) now gets Traditional Chinese native screens and a +Traditional Chinese iOS camera, photo-library and Face ID prompt, where it got +Simplified Chinese before. The bundles `zh` and `pt` are renamed `zh-Hans` and +`pt-BR`. + +The phone language now resolves by script: an explicit Chinese script subtag +decides, `zh-TW` / `zh-HK` / `zh-MO` resolve to `zh-Hant`, any other Chinese +tag to `zh-Hans`, every Portuguese tag (`pt-PT` included) to `pt-BR`, and any +other tag to its primary subtag, else English. This replaces the port of the +Console's exact-then-base `matchLanguageTag`, which is no longer exported; +`resolveLanguageTag(tag)` takes its place. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 7760859..ccfe743 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -23,6 +23,7 @@ pnpm run lint # tsc + ESLint - TypeScript strict; keep policy in pure modules under `lib/` with unit tests, and keep components thin. -- Every user-visible string in `locales/{en,zh,ar}.json`. +- Every user-visible string in every bundle under `locales/` (the 11 languages in + `lib/i18n.ts`); regenerate `zh-Hant.json` from `zh-Hans.json` when that changes. - Add a changeset (`pnpm changeset`) for user-facing changes. - Branch from `main`, open a PR, keep CI green. diff --git a/docs/DEVICE-TESTING.md b/docs/DEVICE-TESTING.md index 1474048..2e99d69 100644 --- a/docs/DEVICE-TESTING.md +++ b/docs/DEVICE-TESTING.md @@ -75,7 +75,7 @@ card. "Dev" means it needs a development build for the tooling named. | #162 Ready signals | kill the app, tap a push notification: the target record opens with no second cover or full page reload | | #163 Device registration | after a push token rotates, a signed-in Console receives `objectstack-native:pushTokenChanged`; `window.ObjectStackNative.installId` survives clearing WebView storage | | #156 Connect | scan a real connect QR code; open an `objectos://connect` link from mail or notes; deny the camera permission | -| #159 Languages | set the phone to Japanese, Arabic and Traditional Chinese (`zh-TW`) in turn: native pages follow, `zh-TW` shows Simplified Chinese, Arabic reads right to left | +| #159 / #182 Languages | set the phone to Japanese, Arabic, 繁體中文 (Taiwan) and Português (Brasil) in turn: native pages follow, Taiwan shows Traditional Chinese (確定, not 确定), Português (Brasil) shows Portuguese, Arabic reads right to left; under 繁體中文 the camera permission prompt (open the QR scanner) is in Traditional Chinese | | #146 Scan | in a Console page that calls `scan()`, a scanned code fills the field; closing the scanner returns `null`; a denied camera permission fails with `failed` | | #152 Saved connections | add two connections and switch between them; remove one and confirm it is signed out; upgrade from a version that saved one server | | #157 MDM | on Android with Test DPC or an EMM, push `serverUrl` and `lockServer=true`: the app goes straight to the confirm page, the switcher has no add-server row, the managed connection cannot be swiped away, and a failed probe says to contact the IT administrator | diff --git a/docs/HYBRID-SHELL.md b/docs/HYBRID-SHELL.md index ac13f86..557b67c 100644 --- a/docs/HYBRID-SHELL.md +++ b/docs/HYBRID-SHELL.md @@ -40,7 +40,7 @@ components/shell/ OfflineFallback.tsx native page for network / 5xx failures AppLockOverlay.tsx the app lock's cover over everything, with Unlock lib/ - i18n.ts shell strings in the Console's ten locales; follows the phone language + i18n.ts shell strings in the platform's 11 languages; follows the phone language rtl.ts right-to-left layout for Arabic server-probe.ts anonymous probe of a server before it is saved server-url.ts persistence of the saved connections (and the one-time migration) @@ -94,20 +94,23 @@ Android WebView profiles), which react-native-webview 13.16 does not expose. The shell translates only its own native screens (server picker, confirm step, loading and offline pages); everything inside the WebView is the Console's. -- **The locale set mirrors the Console UI's.** The shell ships the same ten - locales under the same short codes as the Console's built-in catalogues - (objectstack `ui/packages/i18n/src/locales/`): `en`, `zh`, `ja`, `ko`, `de`, - `fr`, `es`, `pt`, `ru`, `ar`. A test pins `SUPPORTED_LANGUAGES` to that list, - and another fails when any bundle in `locales/` misses a key `en.json` has — - a new native string ships in all ten. -- **The phone language decides, with the Console's matching rule.** The shell - walks the phone's preferred languages in order and takes the first one that - resolves to a bundled locale, else `en`. A tag resolves exactly, else through - its base language: the Console's `matchLanguageTag` - (`ui/packages/i18n/src/locales/registry.ts`), ported as is. So `zh-Hans-CN` → - `zh`, `pt-BR` → `pt`, `es-MX` → `es`, and Traditional Chinese (`zh-TW`, - `zh-HK`, `zh-Hant`) → `zh`, because the Console has no Traditional Chinese - catalogue either. +- **The locale set is the platform's table.** The shell ships the platform's + one list of 11 languages, keyed by the table's tags + (objectstack-ai/objectstack#29428, ruling F of #29349): `en`, `zh-Hans`, + `zh-Hant`, `es`, `ar`, `fr`, `pt-BR`, `ru`, `de`, `ja`, `ko`. A test pins + `SUPPORTED_LANGUAGES` to that list, and another fails when any bundle in + `locales/` misses a key `en.json` has — a new native string ships in all 11. + `zh-Hant.json` is converted from `zh-Hans.json` (opencc-js `s2twp`, with the + phrase overrides of `objectstack-ai/www.objectos.ai` `src/lib/zhconvert.ts`), + then reviewed; convert it again when `zh-Hans.json` changes. +- **The phone language decides, by script.** The shell walks the phone's + preferred languages in order and takes the first one that resolves to a + bundled language, else `en` (`resolveLanguageTag` in `lib/i18n.ts`, the + table's alias rule). For Chinese an explicit script decides (`zh-Hant-*` → + `zh-Hant`, `zh-Hans-*` → `zh-Hans`); without one, `zh-TW`, `zh-HK` and + `zh-MO` → `zh-Hant` and every other `zh` → `zh-Hans`. Every Portuguese tag + (`pt-PT`, `pt-BR`, `pt`) → `pt-BR`. Any other tag resolves to its primary + subtag (`es-MX` → `es`) when that is bundled. - **No in-app language picker.** The phone setting is the only source for the native screens. A phone-language change is picked up the next time the app comes to the foreground. @@ -202,8 +205,9 @@ server's Console and saves nothing. `permissions.camera` in each locale bundle, written by `app.config.ts` into the `expo-camera` and `expo-image-picker` plugins (both write `NSCameraUsageDescription`, so they get the same text) and per-language - `InfoPlist.strings` for all ten shell languages (`zh-Hans`, plus `zh-Hant` - reusing the `zh` text, as the shell's own strings do); both plugins are + `InfoPlist.strings` for all 11 shell languages (`zh-Hans` and `zh-Hant` each + with their own text; `pt-BR` written as the unqualified `pt` so every + Portuguese variant falls back to it); both plugins are configured without a microphone (no prompt, `RECORD_AUDIO` blocked). The photo-library text (`NSPhotoLibraryUsageDescription`, for the bridge's `pickImage()`) is `permissions.photos`, written the same way. diff --git a/docs/design/connection-flow.md b/docs/design/connection-flow.md index dbebf82..106262e 100644 --- a/docs/design/connection-flow.md +++ b/docs/design/connection-flow.md @@ -265,9 +265,10 @@ The timeout in the body follows the user's setting. ## Languages and layout -Every native string ships in the Console UI's ten locales (en, zh, ja, ko, de, fr, es, pt, ru, ar) -and follows the phone's language; Arabic lays out right-to-left (mobile#159). The tables above give -zh and en; the other eight are translations of the en column. +Every native string ships in the platform's 11 languages (en, zh-Hans, zh-Hant, es, ar, fr, pt-BR, +ru, de, ja, ko; the table is objectstack-ai/objectstack#29428) and follows the phone's language; +Arabic lays out right-to-left (mobile#159, mobile#182). The zh column above is zh-Hans; zh-Hant is +converted from it, and the other eight are translations of the en column. ## Updating this design