From 760e82ab503a4185d3bbd40bc9fa875b8b5c405a Mon Sep 17 00:00:00 2001 From: tianma-if Date: Mon, 14 Sep 2026 10:56:53 +0800 Subject: [PATCH] fix(i18n): fall unmatched locales back to English Unknown browser and system languages now resolve to English instead of Simplified Chinese, and Japanese App Store / Play listing copy is kept in store-assets for console publishing. --- apps/api/src/workspace-provisioning.test.mjs | 16 +++ .../Data/Preferences/PreferencesStore.swift | 18 +++- .../EdgeEverTests/MobileUIParityTests.swift | 10 ++ apps/mobile/src/lib/mobile-locale.tsx | 3 +- .../src/screens/workspace-utils.test.ts | 22 ++++ apps/mobile/src/screens/workspace-utils.ts | 7 +- apps/mobile/store-assets/README.md | 34 +++++- .../store-assets/app-store/metadata.ja.md | 94 ++++++++++++++++ .../store-assets/play/metadata.en-US.md | 31 ++++++ .../store-assets/play/metadata.ja-JP.md | 32 ++++++ .../store-assets/play/metadata.zh-CN.md | 30 ++++++ apps/site/src/layouts/Layout.astro | 9 +- apps/web/src/i18n/locales.test.mjs | 51 +++++++++ apps/web/src/i18n/locales.ts | 44 +++----- apps/web/src/mobile-edit.tsx | 3 +- docs/store-delivery.md | 6 ++ docs/store-delivery.zh-CN.md | 5 + packages/shared/package.json | 1 + packages/shared/src/ai-prompt-seeds.ts | 3 +- packages/shared/src/i18n/index.ts | 10 ++ packages/shared/src/i18n/locales.test.mjs | 52 +++++++++ packages/shared/src/i18n/locales.ts | 60 +++++++++++ .../shared/src/memo-template-seeds.test.mjs | 2 + packages/shared/src/memo-template-seeds.ts | 4 +- tests/app-review-login.test.ts | 2 + tests/store-listing-metadata.test.ts | 101 ++++++++++++++++++ tests/ui-locale-fallback.test.ts | 18 ++++ 27 files changed, 628 insertions(+), 40 deletions(-) create mode 100644 apps/mobile/store-assets/app-store/metadata.ja.md create mode 100644 apps/mobile/store-assets/play/metadata.en-US.md create mode 100644 apps/mobile/store-assets/play/metadata.ja-JP.md create mode 100644 apps/mobile/store-assets/play/metadata.zh-CN.md create mode 100644 apps/web/src/i18n/locales.test.mjs create mode 100644 packages/shared/src/i18n/locales.test.mjs create mode 100644 packages/shared/src/i18n/locales.ts create mode 100644 tests/store-listing-metadata.test.ts create mode 100644 tests/ui-locale-fallback.test.ts diff --git a/apps/api/src/workspace-provisioning.test.mjs b/apps/api/src/workspace-provisioning.test.mjs index b00f08e8..0e6283b9 100644 --- a/apps/api/src/workspace-provisioning.test.mjs +++ b/apps/api/src/workspace-provisioning.test.mjs @@ -72,4 +72,20 @@ describe("workspace provisioning", () => { expect(calls.filter((call) => call.sql.includes("INSERT OR IGNORE INTO memo_templates"))).toHaveLength(5); expect(calls.some((call) => call.sql.includes("INSERT OR IGNORE INTO ai_prompt_templates"))).toBe(true); }); + + test("seeds English templates when Accept-Language is neither Chinese nor English", () => { + const calls = []; + const db = { + prepare: (sql) => statement(sql, calls), + }; + + createWorkspaceDefaultSeedStatements( + db, + "ws_ja", + "2026-08-14T00:00:00.000Z", + "ja-JP,ja;q=0.9", + ); + + expect(calls[0].values).toContain("Quick Spark"); + }); }); diff --git a/apps/ios/EdgeEver/Data/Preferences/PreferencesStore.swift b/apps/ios/EdgeEver/Data/Preferences/PreferencesStore.swift index 5b2991f8..13643e25 100644 --- a/apps/ios/EdgeEver/Data/Preferences/PreferencesStore.swift +++ b/apps/ios/EdgeEver/Data/Preferences/PreferencesStore.swift @@ -2,6 +2,17 @@ import Foundation import Observation import SwiftUI +enum AppUILocale { + /// Unmatched system languages use English, not Simplified Chinese. + static func usesEnglish(preferenceCode: String, systemLanguageCode: String?) -> Bool { + if preferenceCode == "en-US" { return true } + if preferenceCode == "zh-CN" { return false } + let code = (systemLanguageCode ?? "").lowercased() + if code.isEmpty { return true } + return !code.hasPrefix("zh") + } +} + @Observable @MainActor final class PreferencesStore { @@ -53,9 +64,10 @@ final class PreferencesStore { } var isEnglish: Bool { - if localeCode == "en-US" { return true } - if localeCode == "zh-CN" { return false } - return Locale.autoupdatingCurrent.language.languageCode?.identifier == "en" + AppUILocale.usesEnglish( + preferenceCode: localeCode, + systemLanguageCode: Locale.autoupdatingCurrent.language.languageCode?.identifier + ) } var colorScheme: ColorScheme? { diff --git a/apps/ios/Tests/EdgeEverTests/MobileUIParityTests.swift b/apps/ios/Tests/EdgeEverTests/MobileUIParityTests.swift index 6c09a60b..16ead062 100644 --- a/apps/ios/Tests/EdgeEverTests/MobileUIParityTests.swift +++ b/apps/ios/Tests/EdgeEverTests/MobileUIParityTests.swift @@ -4,6 +4,16 @@ import XCTest /// Exercises shipped parity helpers (same semantics as Android `@edgeever/shared/mobile-ui` + notebooks). final class MobileUIParityTests: XCTestCase { + func testUnmatchedSystemLanguageUsesEnglishUI() { + XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: "ja")) + XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: "fr")) + XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: "en")) + XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: nil)) + XCTAssertFalse(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: "zh")) + XCTAssertFalse(AppUILocale.usesEnglish(preferenceCode: "zh-CN", systemLanguageCode: "ja")) + XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "en-US", systemLanguageCode: "zh")) + } + func testToggleFilterReturnsToAllWhenPressedAgain() { XCTAssertEqual( MobileUI.toggleMemoFilterMode(current: .all, requested: .pinned), diff --git a/apps/mobile/src/lib/mobile-locale.tsx b/apps/mobile/src/lib/mobile-locale.tsx index 26141406..fd1f4f14 100644 --- a/apps/mobile/src/lib/mobile-locale.tsx +++ b/apps/mobile/src/lib/mobile-locale.tsx @@ -1,4 +1,5 @@ import { enUS, zhCN } from "@edgeever/shared/i18n"; +import { resolveSupportedLocale } from "@edgeever/shared/i18n/locales"; import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; import { readMobileLocalePreference, @@ -214,7 +215,7 @@ const templateTranslations = [ ].sort((left, right) => right.source.length - left.source.length); const resolveSystemLocale = (): SupportedMobileLocale => - (Intl.DateTimeFormat().resolvedOptions().locale || "zh-CN").toLowerCase().startsWith("en") ? "en-US" : "zh-CN"; + resolveSupportedLocale(Intl.DateTimeFormat().resolvedOptions().locale); export const translateMobileText = (value: string, locale: SupportedMobileLocale) => { if (locale !== "en-US" || !/[\u3400-\u9fff]/.test(value)) { diff --git a/apps/mobile/src/screens/workspace-utils.test.ts b/apps/mobile/src/screens/workspace-utils.test.ts index e37799b3..55af24d0 100644 --- a/apps/mobile/src/screens/workspace-utils.test.ts +++ b/apps/mobile/src/screens/workspace-utils.test.ts @@ -6,7 +6,9 @@ import { flattenNotebooks, getNotebookAncestorIds, getNotebookParentIdSet, + getResolvedMobileLocale, getTextSearchMatches, + isEnglishMobileLocale, markdownToLocalText, parseTags, } from "./workspace-utils"; @@ -57,4 +59,24 @@ describe("mobile workspace utilities", () => { expect(formatMemoDetailDate("2010-08-30T12:34:00.000Z", "en-US")).toContain("2010"); expect(formatMemoDetailDate("not-a-date", "zh-CN")).toBe(""); }); + + test("treats explicit locale preferences as the resolved UI locale", () => { + expect(getResolvedMobileLocale("zh-CN")).toBe("zh-CN"); + expect(getResolvedMobileLocale("en-US")).toBe("en-US"); + expect(isEnglishMobileLocale("zh-CN")).toBe(false); + expect(isEnglishMobileLocale("en-US")).toBe(true); + }); + + test("falls unmatched system languages back to English", () => { + const original = Intl.DateTimeFormat.prototype.resolvedOptions; + Intl.DateTimeFormat.prototype.resolvedOptions = function resolvedOptions() { + return { ...original.call(this), locale: "ja-JP" }; + }; + try { + expect(getResolvedMobileLocale("system")).toBe("en-US"); + expect(isEnglishMobileLocale("system")).toBe(true); + } finally { + Intl.DateTimeFormat.prototype.resolvedOptions = original; + } + }); }); diff --git a/apps/mobile/src/screens/workspace-utils.ts b/apps/mobile/src/screens/workspace-utils.ts index 249d1f92..59865b24 100644 --- a/apps/mobile/src/screens/workspace-utils.ts +++ b/apps/mobile/src/screens/workspace-utils.ts @@ -1,4 +1,5 @@ import { DEFAULT_MEMO_TITLE, type MemoSummary, type Notebook } from "@edgeever/shared"; +import { resolveSupportedLocale } from "@edgeever/shared/i18n/locales"; import type { MobileLocalePreference } from "../lib/preferences"; export type NotebookOption = { @@ -113,10 +114,12 @@ export const filterCollapsedNotebookOptions = (options: NotebookOption[], collap }; export const getResolvedMobileLocale = (localePreference: MobileLocalePreference) => - localePreference === "system" ? Intl.DateTimeFormat().resolvedOptions().locale || "zh-CN" : localePreference; + localePreference === "system" + ? resolveSupportedLocale(Intl.DateTimeFormat().resolvedOptions().locale) + : localePreference; export const isEnglishMobileLocale = (localePreference: MobileLocalePreference) => - getResolvedMobileLocale(localePreference).startsWith("en"); + getResolvedMobileLocale(localePreference) === "en-US"; export const formatDate = (value: string, localePreference: MobileLocalePreference = "system") => new Intl.DateTimeFormat(getResolvedMobileLocale(localePreference), { diff --git a/apps/mobile/store-assets/README.md b/apps/mobile/store-assets/README.md index 3eca3c9d..9eb88c2c 100644 --- a/apps/mobile/store-assets/README.md +++ b/apps/mobile/store-assets/README.md @@ -3,6 +3,10 @@ This directory contains the current EdgeEver store listing artwork, screenshots, and metadata. +Listing copy is pasted in App Store Connect and Play Console. It is not uploaded +by `bun run publish:stores`. That command only delivers store binaries; adding +an incomplete Japanese locale there would block App Review. + The root-level artwork is used by Google Play: - `feature-graphic.png`: 1024 × 500 feature graphic. @@ -10,5 +14,33 @@ The root-level artwork is used by Google Play: - `phone-*-1080x1920.jpg`: Play-ready portrait screenshots used for phone and tablet listing slots. The `app-store/` directory contains the App Store metadata source of truth and -Apple-ready iPhone screenshots. Keep the English and Simplified Chinese +Apple-ready iPhone screenshots. Keep English, Simplified Chinese, and Japanese metadata files aligned when changing listing content. + +The `play/` directory contains Google Play listing copy for English, Simplified +Chinese, and Japanese. + +## Publish a Japanese listing + +Japanese in-app UI is not required. Japanese users currently see English chrome. +Reuse the existing screenshots. + +### App Store Connect + +1. Open the EdgeEver app → App Information. +2. Add the **Japanese** (`ja`) localization if it is missing. +3. Paste `app-store/metadata.ja.md` into name, subtitle, promotional text, + description, keywords, URLs, and the current version's What's New. +4. Save. Do not attach this locale to a binary review until those fields are + complete. + +### Google Play Console + +Play may already show Japanese from an earlier translation. Replace it with the +copy in this repository. + +1. Open Grow → Store presence → Main store listing → Manage translations. +2. Add **Japanese (Japan)** if it is missing. +3. Paste `play/metadata.ja-JP.md` into title, short description, and full + description. +4. Publish the store listing. This does not upload a new AAB. diff --git a/apps/mobile/store-assets/app-store/metadata.ja.md b/apps/mobile/store-assets/app-store/metadata.ja.md new file mode 100644 index 00000000..8436d0d7 --- /dev/null +++ b/apps/mobile/store-assets/app-store/metadata.ja.md @@ -0,0 +1,94 @@ +# EdgeEver App Store metadata (ja) + +Paste into App Store Connect → App Information / version localization → Japanese (`ja`). +Do not submit this locale through `bun run publish:stores`. Fastlane only uploads +English and Simplified Chinese "What's New" during binary review. + +## App information + +- Name: EdgeEver +- Subtitle: セルフホストのノートワークスペース +- Primary category: 仕事効率化 +- Secondary category: ユーティリティ +- Content rights: EdgeEver は初期状態で第三者のコンテンツを含みません。ユーザーは自分で管理する EdgeEver インスタンスにアプリを接続します。 +- Privacy policy URL: https://edgeever.org/en/privacy + +## Version listing + +### Promotional text + +自分で運用するオープンソースの EdgeEver に接続して、ノートをすばやく記録・整理・同期できます。データは自分の手元に置けます。 + +### Description + +EdgeEver は、オープンソースでセルフホストできるノートワークスペースです。書き心地のよさと、データの主導権を両立したい人向けです。 + +自分または所属組織が運用する EdgeEver インスタンスにアプリを接続すると、iPhone からノートブック、ノート、タグ、添付ファイル、検索、ゴミ箱を使えます。 + +主な機能: + +• ノートブックとノートのすばやい閲覧 +• リッチテキストでのノート編集 +• 入れ子のノートブックとタグ +• ワークスペース全体の検索 +• 画像と添付ファイルのアップロード +• 通信が不安定なときのローカル下書きと同期キュー +• 端末内に安全に保存されるログイン情報 +• 広告、解析、追跡、テレメトリーなし + +EdgeEver は、あなた自身の Cloudflare アカウント上で動作します。ノートや認証情報を保管・中継する EdgeEver の中央サービスはありません。利用するには、edgeever.org の案内に従ってインスタンスを導入するか、所属組織が用意したインスタンスに接続してください。 + +EdgeEver は独立したプロジェクトであり、Evernote Corporation およびその関連会社とは提携、後援、承認の関係にありません。 + +### Keywords + +ノート,メモ帳,ノートブック,セルフホスト,Markdown,知識管理,同期,オープンソース,Cloudflare,効率化 + +### URLs + +- Support URL: https://edgeever.org/en/contact +- Marketing URL: https://edgeever.org/en/ + +### Copyright + +2026 Zhengzhou Binggui Network Technology Co., Ltd. + +### What's New + +端末からモデル提供元へ直接接続し、AI によるノート生成をストリーミングします。クラウド上のインスタンスを経由しません。このクライアントが接続先インスタンスより新しい場合は、対応する Cloudflare または Docker の更新手順をシステム情報に静かに表示します。 + +## App Review information + +- Demo instance URL: https://demo.edgeever.org +- Username: ee-demo +- Password: demo#dZ6Q29Zjfor% +- Contact first name: Shihao +- Contact last name: Ma +- Contact phone: +86 185 3927 0558 +- Contact email: 1245207870@qq.com + +### Review notes + +APP REVIEW LOGIN — paste these values into the sign-in form. + +Demo Instance URL (type this exact HTTPS URL into Instance URL; do not type "demo"): +https://demo.edgeever.org + +Username: ee-demo +Password: demo#dZ6Q29Zjfor% + +How to sign in: +1. Launch EdgeEver. +2. In Instance URL, enter https://demo.edgeever.org +3. Enter the username and password above. +4. Tap Sign in. You should land in a notes workspace with sample content. + +Do not enter "demo" alone, and do not use any example.com placeholder. + +EdgeEver is a self-hosted personal knowledge base. Users connect the app to their own instance URL; there is no shared SaaS backend. The public demo is for App Review and resets daily around 03:00 China Standard Time (19:00 UTC). Please do not store private data there. + +HTTPS is required for the public demo. HTTP is only for local/LAN self-hosting and shows an explicit warning. + +The app does not create accounts. Production accounts are created and controlled by the administrator of the self-hosted EdgeEver instance. Notes, attachments, credentials, and sync traffic travel directly between the device and the instance selected by the user; the app developer operates no relay or centralized storage service. Account and server-side data deletion is handled by that instance administrator. Users can sign out to remove the local session and can remove local app data through iOS Settings. + +The app uses only standard encryption supplied by the operating system and HTTPS libraries and does not implement proprietary or non-standard cryptography. diff --git a/apps/mobile/store-assets/play/metadata.en-US.md b/apps/mobile/store-assets/play/metadata.en-US.md new file mode 100644 index 00000000..6c98285e --- /dev/null +++ b/apps/mobile/store-assets/play/metadata.en-US.md @@ -0,0 +1,31 @@ +# EdgeEver Google Play metadata (en-US) + +Source of truth for the default Play listing. Paste into Play Console → +Main store listing → English (United States). + +## Title + +EdgeEver + +## Short description + +Private notes for your self-hosted EdgeEver workspace. + +## Full description + +EdgeEver is an open-source, self-hosted notes workspace built for fast capture, focused organization, and reliable access across devices. + +Connect the app directly to your own EdgeEver instance and keep control of where your data lives. + +• Browse notebooks, notes, tags, and attachments +• Search your local note mirror instantly +• Create and edit notes even when the network is unreliable +• Sync queued changes with your self-hosted workspace in the background +• Organize content with nested notebooks and tags +• Add images and file attachments +• Review note history and manage your workspace +• Use a clean mobile interface aligned with the EdgeEver web experience + +EdgeEver contains no ads, analytics, tracking, or telemetry. The app connects directly to the instance you choose; the project maintainers do not operate a relay that receives or stores your notes. + +An EdgeEver self-hosted instance is required. Learn more and deploy your own workspace at edgeever.org. diff --git a/apps/mobile/store-assets/play/metadata.ja-JP.md b/apps/mobile/store-assets/play/metadata.ja-JP.md new file mode 100644 index 00000000..aab146d6 --- /dev/null +++ b/apps/mobile/store-assets/play/metadata.ja-JP.md @@ -0,0 +1,32 @@ +# EdgeEver Google Play metadata (ja-JP) + +Paste into Play Console → Main store listing → Japanese (Japan). +Do not ship this locale through `bun run publish:stores`; listing copy is +console metadata, not a store binary. + +## Title + +EdgeEver + +## Short description + +自分のサーバーで使う、オープンソースのノートアプリ + +## Full description + +EdgeEver は、すばやく書き留め、整理し、どの端末からでも安定して開ける、オープンソースのセルフホスト型ノートワークスペースです。 + +アプリをご自身の EdgeEver インスタンスに直接接続し、データの置き場所を自分で管理できます。 + +• ノートブック、ノート、タグ、添付ファイルを閲覧 +• 端末内のノートミラーをすぐ検索 +• 通信が不安定でもノートの作成と編集が可能 +• キューに入れた変更をバックグラウンドで同期 +• 入れ子のノートブックとタグで整理 +• 画像やファイルを添付 +• ノートの履歴を確認し、ワークスペースを管理 +• Web 版と揃えた、すっきりしたモバイル画面 + +広告、解析、追跡、テレメトリーはありません。アプリはあなたが選んだインスタンスに直接接続します。プロジェクトの運営者は、ノートを受け取ったり保存したりする中継サーバーを運用していません。 + +利用にはセルフホストした EdgeEver インスタンスが必要です。詳しい案内と導入手順は edgeever.org をご覧ください。 diff --git a/apps/mobile/store-assets/play/metadata.zh-CN.md b/apps/mobile/store-assets/play/metadata.zh-CN.md new file mode 100644 index 00000000..dba9bfa9 --- /dev/null +++ b/apps/mobile/store-assets/play/metadata.zh-CN.md @@ -0,0 +1,30 @@ +# EdgeEver Google Play 元数据(简体中文) + +Play 商店简体中文文案的来源。粘贴到 Play Console → 商店列表 → 中文(简体)。 + +## 标题 + +EdgeEver + +## 简短说明 + +为你的自托管 EdgeEver 工作区记录私人笔记。 + +## 完整说明 + +EdgeEver 是开源、自托管的笔记工作区,适合快速记录、专注整理,并在各设备间可靠访问。 + +将应用直接连接到你自己的 EdgeEver 实例,由你决定数据存放在哪里。 + +• 浏览笔记本、笔记、标签和附件 +• 即时搜索本机笔记镜像 +• 网络不稳定时也能创建和编辑笔记 +• 在后台把排队中的改动同步到自托管工作区 +• 用嵌套笔记本和标签整理内容 +• 添加图片和文件附件 +• 查看笔记历史并管理工作区 +• 使用与 EdgeEver 网页端一致的简洁移动界面 + +EdgeEver 不含广告、分析、追踪或遥测。应用直接连接到你选择的实例;项目维护者不运营接收或存储笔记的中转服务。 + +使用前需要自托管的 EdgeEver 实例。请访问 edgeever.org 了解详情并部署自己的工作区。 diff --git a/apps/site/src/layouts/Layout.astro b/apps/site/src/layouts/Layout.astro index 3ba7e4bc..b6c66107 100644 --- a/apps/site/src/layouts/Layout.astro +++ b/apps/site/src/layouts/Layout.astro @@ -60,13 +60,13 @@ const localeRedirectScript = `(() => { const supportedLocales = ["zh-CN", "en-US"]; const normalizeLocale = (locale) => { if (!locale) return null; - const normalized = String(locale).toLowerCase(); + const normalized = String(locale).trim().replaceAll("_", "-").toLowerCase(); if (normalized === "zh" || normalized.startsWith("zh-")) { return "zh-CN"; } - if (normalized === "en" || normalized === "en-us" || normalized.startsWith("en-")) { + if (normalized === "en" || normalized.startsWith("en-")) { return "en-US"; } @@ -90,8 +90,11 @@ const localeRedirectScript = `(() => { }; const getBrowserLocale = () => { const browserLocales = navigator.languages?.length ? navigator.languages : [navigator.language]; + let sawCandidate = false; for (const locale of browserLocales) { + if (!locale) continue; + sawCandidate = true; const supported = normalizeLocale(locale); if (supported) { @@ -99,7 +102,7 @@ const localeRedirectScript = `(() => { } } - return null; + return sawCandidate ? "en-US" : null; }; const getCurrentLocale = (pathname) => pathname === "/en" || pathname.startsWith("/en/") ? "en-US" : "zh-CN"; const isExplicitLocalePath = (pathname) => pathname === "/en" || pathname.startsWith("/en/"); diff --git a/apps/web/src/i18n/locales.test.mjs b/apps/web/src/i18n/locales.test.mjs new file mode 100644 index 00000000..54cbcf4c --- /dev/null +++ b/apps/web/src/i18n/locales.test.mjs @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { defaultLocale, getBrowserLocale, getInitialLocale, normalizeLocale } from "./locales.ts"; + +const originalNavigator = globalThis.navigator; +const originalWindow = globalThis.window; + +const setNavigatorLanguages = (languages, language = languages[0]) => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { languages, language }, + }); +}; + +afterEach(() => { + if (originalNavigator === undefined) { + delete globalThis.navigator; + } else { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: originalNavigator, + }); + } + if (originalWindow === undefined) { + delete globalThis.window; + } else { + globalThis.window = originalWindow; + } +}); + +describe("web locale resolution", () => { + test("keeps Chinese and English browser languages on their shipped locales", () => { + setNavigatorLanguages(["zh-TW", "en-US"]); + expect(getBrowserLocale()).toBe("zh-CN"); + setNavigatorLanguages(["en-GB"]); + expect(getBrowserLocale()).toBe("en-US"); + expect(normalizeLocale("zh-Hans")).toBe("zh-CN"); + }); + + test("falls unmatched browser languages back to English instead of Chinese", () => { + setNavigatorLanguages(["ja-JP", "ja"]); + expect(getBrowserLocale()).toBe("en-US"); + setNavigatorLanguages(["fr-FR", "de-DE"]); + expect(getInitialLocale()).toBe("en-US"); + expect(defaultLocale).toBe("zh-CN"); + }); + + test("uses the first supported language in the browser preference list", () => { + setNavigatorLanguages(["ja-JP", "zh-CN"]); + expect(getBrowserLocale()).toBe("zh-CN"); + }); +}); diff --git a/apps/web/src/i18n/locales.ts b/apps/web/src/i18n/locales.ts index eb418a9c..363bd6da 100644 --- a/apps/web/src/i18n/locales.ts +++ b/apps/web/src/i18n/locales.ts @@ -1,9 +1,17 @@ -export const supportedLocales = ["zh-CN", "en-US"] as const; +import { + defaultLocale as sharedDefaultLocale, + matchSupportedLocale, + resolveSupportedLocaleFromCandidates, + supportedLocales as sharedSupportedLocales, + type SupportedLocale as SharedSupportedLocale, +} from "@edgeever/shared/i18n/locales"; -export type SupportedLocale = (typeof supportedLocales)[number]; +export const supportedLocales = sharedSupportedLocales; + +export type SupportedLocale = SharedSupportedLocale; export type AppLocalePreference = "system" | SupportedLocale; -export const defaultLocale: SupportedLocale = "zh-CN"; +export const defaultLocale: SupportedLocale = sharedDefaultLocale; export const localeStorageKey = "edgeever.locale.preference"; const legacyLocaleStorageKey = "edgeever.locale"; @@ -13,23 +21,8 @@ export const localeLabels: Record = { "en-US": "English", }; -export const normalizeLocale = (locale: string | null | undefined): SupportedLocale | null => { - if (!locale) { - return null; - } - - const normalized = locale.toLowerCase(); - - if (normalized === "zh" || normalized.startsWith("zh-")) { - return "zh-CN"; - } - - if (normalized === "en" || normalized === "en-us" || normalized.startsWith("en-")) { - return "en-US"; - } - - return null; -}; +export const normalizeLocale = (locale: string | null | undefined): SupportedLocale | null => + matchSupportedLocale(locale); export const readStoredLocale = (): SupportedLocale | null => { try { @@ -70,6 +63,7 @@ export const clearStoredLocale = () => { export const getAppLocalePreference = (): AppLocalePreference => readStoredLocale() ?? "system"; +/** Returns null only when the browser exposes no language list; unmatched languages become English. */ export const getBrowserLocale = (): SupportedLocale | null => { if (typeof navigator === "undefined") { return null; @@ -77,15 +71,11 @@ export const getBrowserLocale = (): SupportedLocale | null => { const browserLocales = navigator.languages?.length ? navigator.languages : [navigator.language]; - for (const locale of browserLocales) { - const supported = normalizeLocale(locale); - - if (supported) { - return supported; - } + if (!browserLocales.some((locale) => locale?.trim())) { + return null; } - return null; + return resolveSupportedLocaleFromCandidates(browserLocales); }; export const getInitialLocale = () => readStoredLocale() ?? getBrowserLocale() ?? defaultLocale; diff --git a/apps/web/src/mobile-edit.tsx b/apps/web/src/mobile-edit.tsx index 54b9f6f6..0b1bdcf6 100644 --- a/apps/web/src/mobile-edit.tsx +++ b/apps/web/src/mobile-edit.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRoot } from "react-dom/client"; import { MobileStandaloneTiptapEditor } from "@/components/MobileStandaloneTiptapEditor"; import { initializeTheme } from "@/components/ThemeProvider"; +import { defaultLocale, getBrowserLocale } from "@/i18n/locales"; import "./i18n"; import "./styles/mobile-markdown-editor.css"; @@ -42,7 +43,7 @@ class MobileEditorErrorBoundary extends React.Component
diff --git a/docs/store-delivery.md b/docs/store-delivery.md index a741826a..f410c451 100644 --- a/docs/store-delivery.md +++ b/docs/store-delivery.md @@ -133,3 +133,9 @@ Fastlane (`apps/ios` `submit_review`) then selects the exact app version and build number, submits App Review, and configures automatic release after approval. Missing metadata, agreements, review information, or credentials cause the workflow to fail without submitting a different build. + +Store listing localizations (including Japanese) live in +`apps/mobile/store-assets/` and are pasted in App Store Connect and Play +Console. Do not add a new locale to Fastlane `submit_review` until that +locale is complete in the console. An incomplete localization can fail +App Review without changing the binary. diff --git a/docs/store-delivery.zh-CN.md b/docs/store-delivery.zh-CN.md index f34889fc..73204dce 100644 --- a/docs/store-delivery.zh-CN.md +++ b/docs/store-delivery.zh-CN.md @@ -119,3 +119,8 @@ App Store Connect API Key 上传 App Store IPA。随后 Fastlane(`apps/ios` `submit_review`)精确选择相同的 App Version 与 Build Number,提交 App Review, 并设置为审核通过后自动发布。元数据、协议、审核信息或凭据不完整时工作流会失败, 不会改为提交其他构建。 + +商店列表本地化(含日文)存放在 `apps/mobile/store-assets/`,需在 App Store +Connect 和 Play Console 中粘贴发布。在控制台补齐该语言的必填字段之前,不要把 +新语言加入 Fastlane `submit_review`。不完整的本地化会在不改二进制的情况下让 +App Review 失败。 diff --git a/packages/shared/package.json b/packages/shared/package.json index 1280f3ce..4c893202 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -13,6 +13,7 @@ "./image-gallery-editing": "./src/image-gallery-editing.ts", "./native-image-gallery": "./src/native-image-gallery.ts", "./i18n": "./src/i18n/index.ts", + "./i18n/locales": "./src/i18n/locales.ts", "./memo-template-seeds": "./src/memo-template-seeds.ts", "./mathematics": "./src/mathematics.ts", "./mobile-editor": "./src/mobile-editor.ts", diff --git a/packages/shared/src/ai-prompt-seeds.ts b/packages/shared/src/ai-prompt-seeds.ts index 512245a9..4a27b292 100644 --- a/packages/shared/src/ai-prompt-seeds.ts +++ b/packages/shared/src/ai-prompt-seeds.ts @@ -3,6 +3,7 @@ import type { AiPromptParameterKind, AiPromptResultMode, } from "./ai-assistant"; +import { resolveSupportedLocale } from "./i18n/locales"; export type AiPromptSeedKey = Exclude; export type AiPromptSeedLocale = "zh-CN" | "en-US"; @@ -33,7 +34,7 @@ const seed = ( }); export const normalizeAiPromptSeedLocale = (locale: string | null | undefined): AiPromptSeedLocale => - locale?.toLowerCase().startsWith("en") ? "en-US" : "zh-CN"; + resolveSupportedLocale(locale); export const localizeAiPromptSeed = ( promptSeed: AiPromptSeed, diff --git a/packages/shared/src/i18n/index.ts b/packages/shared/src/i18n/index.ts index d895154e..f98b3101 100644 --- a/packages/shared/src/i18n/index.ts +++ b/packages/shared/src/i18n/index.ts @@ -1,2 +1,12 @@ export { enUS } from "./en-US"; export { zhCN } from "./zh-CN"; +export { + defaultLocale, + matchSupportedLocale, + parseAcceptLanguage, + resolveSupportedLocale, + resolveSupportedLocaleFromCandidates, + supportedLocales, + unmatchedLocale, + type SupportedLocale, +} from "./locales"; diff --git a/packages/shared/src/i18n/locales.test.mjs b/packages/shared/src/i18n/locales.test.mjs new file mode 100644 index 00000000..91961d37 --- /dev/null +++ b/packages/shared/src/i18n/locales.test.mjs @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { + defaultLocale, + matchSupportedLocale, + parseAcceptLanguage, + resolveSupportedLocale, + resolveSupportedLocaleFromCandidates, + unmatchedLocale, +} from "./locales.ts"; + +describe("supported locale matching", () => { + test("maps Chinese and English families onto the two shipped locales", () => { + expect(matchSupportedLocale("zh")).toBe("zh-CN"); + expect(matchSupportedLocale("zh-CN")).toBe("zh-CN"); + expect(matchSupportedLocale("zh_TW")).toBe("zh-CN"); + expect(matchSupportedLocale("zh-Hans-CN")).toBe("zh-CN"); + expect(matchSupportedLocale("en")).toBe("en-US"); + expect(matchSupportedLocale("en-GB")).toBe("en-US"); + expect(matchSupportedLocale("en_US")).toBe("en-US"); + }); + + test("does not treat unrelated tags as Chinese or English", () => { + expect(matchSupportedLocale("ja")).toBeNull(); + expect(matchSupportedLocale("ja-JP")).toBeNull(); + expect(matchSupportedLocale("fr-FR")).toBeNull(); + expect(matchSupportedLocale("pt-BR")).toBeNull(); + expect(matchSupportedLocale("english")).toBeNull(); + expect(matchSupportedLocale(null)).toBeNull(); + }); +}); + +describe("supported locale resolution", () => { + test("keeps Chinese as the empty default and English as the unmatched fallback", () => { + expect(defaultLocale).toBe("zh-CN"); + expect(unmatchedLocale).toBe("en-US"); + expect(resolveSupportedLocale(null)).toBe("zh-CN"); + expect(resolveSupportedLocale("")).toBe("zh-CN"); + expect(resolveSupportedLocale("ja-JP")).toBe("en-US"); + expect(resolveSupportedLocale("ko")).toBe("en-US"); + expect(resolveSupportedLocale("de-DE")).toBe("en-US"); + }); + + test("walks browser or Accept-Language candidate lists in preference order", () => { + expect(parseAcceptLanguage("ja-JP,ja;q=0.9,en-US;q=0.8")).toEqual(["ja-JP", "ja", "en-US"]); + expect(resolveSupportedLocaleFromCandidates(["ja-JP", "en-US"])).toBe("en-US"); + expect(resolveSupportedLocaleFromCandidates(["ja-JP", "zh-CN"])).toBe("zh-CN"); + expect(resolveSupportedLocaleFromCandidates(["fr-FR", "de-DE"])).toBe("en-US"); + expect(resolveSupportedLocale("ja-JP,zh-CN;q=0.8")).toBe("zh-CN"); + expect(resolveSupportedLocale("fr-FR,fr;q=0.9")).toBe("en-US"); + expect(resolveSupportedLocale("zh-CN,zh;q=0.9,en;q=0.8")).toBe("zh-CN"); + }); +}); diff --git a/packages/shared/src/i18n/locales.ts b/packages/shared/src/i18n/locales.ts new file mode 100644 index 00000000..f18047f1 --- /dev/null +++ b/packages/shared/src/i18n/locales.ts @@ -0,0 +1,60 @@ +export const supportedLocales = ["zh-CN", "en-US"] as const; + +export type SupportedLocale = (typeof supportedLocales)[number]; + +/** Used when there is no locale evidence (legacy callers, missing headers). */ +export const defaultLocale: SupportedLocale = "zh-CN"; + +/** Used when locale evidence exists but is neither Chinese nor English. */ +export const unmatchedLocale: SupportedLocale = "en-US"; + +export const matchSupportedLocale = (locale: string | null | undefined): SupportedLocale | null => { + if (!locale) { + return null; + } + + const normalized = locale.trim().replaceAll("_", "-").toLowerCase(); + + if (normalized === "zh" || normalized.startsWith("zh-")) { + return "zh-CN"; + } + + if (normalized === "en" || normalized.startsWith("en-")) { + return "en-US"; + } + + return null; +}; + +export const parseAcceptLanguage = (value: string): string[] => + value + .split(",") + .map((part) => part.split(";")[0]?.trim() ?? "") + .filter(Boolean); + +export const resolveSupportedLocaleFromCandidates = ( + candidates: readonly (string | null | undefined)[], +): SupportedLocale => { + let sawCandidate = false; + + for (const candidate of candidates) { + if (!candidate || !candidate.trim()) { + continue; + } + + sawCandidate = true; + + for (const tag of parseAcceptLanguage(candidate)) { + const matched = matchSupportedLocale(tag); + + if (matched) { + return matched; + } + } + } + + return sawCandidate ? unmatchedLocale : defaultLocale; +}; + +export const resolveSupportedLocale = (locale?: string | null): SupportedLocale => + resolveSupportedLocaleFromCandidates(locale ? [locale] : []); diff --git a/packages/shared/src/memo-template-seeds.test.mjs b/packages/shared/src/memo-template-seeds.test.mjs index 67bccae7..e6c1c2cf 100644 --- a/packages/shared/src/memo-template-seeds.test.mjs +++ b/packages/shared/src/memo-template-seeds.test.mjs @@ -11,6 +11,8 @@ describe("memo template seeds", () => { expect(new Set(DEFAULT_MEMO_TEMPLATE_SEEDS.map((seed) => seed.key)).size).toBe(5); expect(localizeMemoTemplateSeed(DEFAULT_MEMO_TEMPLATE_SEEDS[0], "zh-CN").title).toBe("灵感速记"); expect(localizeMemoTemplateSeed(DEFAULT_MEMO_TEMPLATE_SEEDS[0], "en-GB").title).toBe("Quick Spark"); + expect(localizeMemoTemplateSeed(DEFAULT_MEMO_TEMPLATE_SEEDS[0], "ja-JP").title).toBe("Quick Spark"); + expect(localizeMemoTemplateSeed(DEFAULT_MEMO_TEMPLATE_SEEDS[0], null).title).toBe("灵感速记"); }); test("uses workspace-scoped deterministic ids", () => { diff --git a/packages/shared/src/memo-template-seeds.ts b/packages/shared/src/memo-template-seeds.ts index 3d015254..ca5f0d28 100644 --- a/packages/shared/src/memo-template-seeds.ts +++ b/packages/shared/src/memo-template-seeds.ts @@ -1,3 +1,5 @@ +import { resolveSupportedLocale } from "./i18n/locales"; + export type MemoTemplateSeedLocale = "zh-CN" | "en-US"; export type MemoTemplateSeedTranslation = { @@ -92,7 +94,7 @@ export const DEFAULT_MEMO_TEMPLATE_SEEDS: readonly MemoTemplateSeed[] = [ export const normalizeMemoTemplateSeedLocale = ( locale: string | null | undefined, -): MemoTemplateSeedLocale => locale?.toLowerCase().startsWith("en") ? "en-US" : "zh-CN"; +): MemoTemplateSeedLocale => resolveSupportedLocale(locale); export const localizeMemoTemplateSeed = ( templateSeed: MemoTemplateSeed, diff --git a/tests/app-review-login.test.ts b/tests/app-review-login.test.ts index 07b70009..beabe3ca 100644 --- a/tests/app-review-login.test.ts +++ b/tests/app-review-login.test.ts @@ -13,6 +13,7 @@ const loginSurfaces = [ const reviewNotes = [ "apps/mobile/store-assets/app-store/metadata.en-US.md", "apps/mobile/store-assets/app-store/metadata.zh-CN.md", + "apps/mobile/store-assets/app-store/metadata.ja.md", ]; describe("App Review login", () => { @@ -39,5 +40,6 @@ describe("App Review login", () => { } expect(read("apps/mobile/store-assets/app-store/metadata.en-US.md")).toContain("Username: ee-demo"); expect(read("apps/mobile/store-assets/app-store/metadata.zh-CN.md")).toContain("用户名:ee-demo"); + expect(read("apps/mobile/store-assets/app-store/metadata.ja.md")).toContain("Username: ee-demo"); }); }); diff --git a/tests/store-listing-metadata.test.ts b/tests/store-listing-metadata.test.ts new file mode 100644 index 00000000..b86cb78c --- /dev/null +++ b/tests/store-listing-metadata.test.ts @@ -0,0 +1,101 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; + +const read = (relativePath: string) => + readFileSync(new URL(`../${relativePath}`, import.meta.url), "utf8"); + +const APP_STORE_LIMITS = { + name: 30, + subtitle: 30, + promotionalText: 170, + description: 4000, + keywords: 100, + whatsNew: 4000, +}; + +const PLAY_LIMITS = { + title: 50, + shortDescription: 80, + fullDescription: 4000, +}; + +const field = (source: string, label: string) => { + const match = source.match(new RegExp(`^- ${label}: (.+)$`, "m")); + if (!match) { + throw new Error(`Missing field: ${label}`); + } + return match[1].trim(); +}; + +const headingBlock = (source: string, heading: string) => { + const match = source.match( + new RegExp(`(?:### |## )${heading}\\n\\n([\\s\\S]*?)(?:\\n\\n(?:### |## )|\\n## |$)`), + ); + if (!match) { + throw new Error(`Missing heading: ${heading}`); + } + return match[1].trim(); +}; + +describe("App Store listing copy", () => { + const english = read("apps/mobile/store-assets/app-store/metadata.en-US.md"); + const chinese = read("apps/mobile/store-assets/app-store/metadata.zh-CN.md"); + const japanese = read("apps/mobile/store-assets/app-store/metadata.ja.md"); + + test("keeps Japanese fields within App Store Connect limits", () => { + expect(field(japanese, "Name").length).toBeLessThanOrEqual(APP_STORE_LIMITS.name); + expect(field(japanese, "Subtitle").length).toBeLessThanOrEqual(APP_STORE_LIMITS.subtitle); + expect(headingBlock(japanese, "Promotional text").length).toBeLessThanOrEqual( + APP_STORE_LIMITS.promotionalText, + ); + expect(headingBlock(japanese, "Description").length).toBeLessThanOrEqual( + APP_STORE_LIMITS.description, + ); + expect(headingBlock(japanese, "Keywords").length).toBeLessThanOrEqual(APP_STORE_LIMITS.keywords); + expect(headingBlock(japanese, "What's New").length).toBeLessThanOrEqual(APP_STORE_LIMITS.whatsNew); + }); + + test("points Japanese listing URLs at the English site and keeps the Evernote disclaimer", () => { + expect(field(japanese, "Privacy policy URL")).toBe("https://edgeever.org/en/privacy"); + expect(japanese).toContain("https://edgeever.org/en/contact"); + expect(japanese).toContain("https://edgeever.org/en/"); + expect(headingBlock(japanese, "Description")).toContain("Evernote Corporation"); + expect(headingBlock(japanese, "Keywords")).not.toContain("Evernote"); + }); + + test("keeps App Review demo credentials in every App Store locale file", () => { + for (const source of [english, chinese, japanese]) { + expect(source).toContain("https://demo.edgeever.org"); + expect(source).toContain("ee-demo"); + expect(source).toContain("demo#dZ6Q29Zjfor%"); + expect(source).not.toContain("notes.example.com"); + } + }); +}); + +describe("Google Play listing copy", () => { + const english = read("apps/mobile/store-assets/play/metadata.en-US.md"); + const chinese = read("apps/mobile/store-assets/play/metadata.zh-CN.md"); + const japanese = read("apps/mobile/store-assets/play/metadata.ja-JP.md"); + + test("keeps Play listing fields within Console limits", () => { + for (const [source, titleHeading, shortHeading, fullHeading] of [ + [english, "Title", "Short description", "Full description"], + [chinese, "标题", "简短说明", "完整说明"], + [japanese, "Title", "Short description", "Full description"], + ] as const) { + expect(headingBlock(source, titleHeading).length).toBeLessThanOrEqual(PLAY_LIMITS.title); + expect(headingBlock(source, shortHeading).length).toBeLessThanOrEqual( + PLAY_LIMITS.shortDescription, + ); + expect(headingBlock(source, fullHeading).length).toBeLessThanOrEqual( + PLAY_LIMITS.fullDescription, + ); + } + }); + + test("tells Japanese Play users that a self-hosted instance is required", () => { + expect(headingBlock(japanese, "Full description")).toContain("セルフホスト"); + expect(headingBlock(japanese, "Full description")).toContain("edgeever.org"); + }); +}); diff --git a/tests/ui-locale-fallback.test.ts b/tests/ui-locale-fallback.test.ts new file mode 100644 index 00000000..e26b4705 --- /dev/null +++ b/tests/ui-locale-fallback.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; + +const read = (relativePath: string) => readFileSync(new URL(`../${relativePath}`, import.meta.url), "utf8"); + +describe("unmatched locale fallback", () => { + test("site, Android, and iOS resolve non-Chinese system languages to English", () => { + const siteLayout = read("apps/site/src/layouts/Layout.astro"); + const mobileLocale = read("apps/mobile/src/lib/mobile-locale.tsx"); + const mobileUtils = read("apps/mobile/src/screens/workspace-utils.ts"); + const iosPreferences = read("apps/ios/EdgeEver/Data/Preferences/PreferencesStore.swift"); + + expect(siteLayout).toContain('return sawCandidate ? "en-US" : null'); + expect(mobileLocale).toContain("resolveSupportedLocale(Intl.DateTimeFormat().resolvedOptions().locale)"); + expect(mobileUtils).toContain("resolveSupportedLocale(Intl.DateTimeFormat().resolvedOptions().locale)"); + expect(iosPreferences).toContain("return !code.hasPrefix(\"zh\")"); + }); +});