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.
This commit is contained in:
1 parent
0e5be33a44
commit
760e82ab50
27 files changed
+628
-40
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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? {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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), {
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 をご覧ください。
|
||||
@@ -0,0 +1,30 @@
|
||||
# EdgeEver Google Play 元数据(简体中文)
|
||||
|
||||
Play 商店简体中文文案的来源。粘贴到 Play Console → 商店列表 → 中文(简体)。
|
||||
|
||||
## 标题
|
||||
|
||||
EdgeEver
|
||||
|
||||
## 简短说明
|
||||
|
||||
为你的自托管 EdgeEver 工作区记录私人笔记。
|
||||
|
||||
## 完整说明
|
||||
|
||||
EdgeEver 是开源、自托管的笔记工作区,适合快速记录、专注整理,并在各设备间可靠访问。
|
||||
|
||||
将应用直接连接到你自己的 EdgeEver 实例,由你决定数据存放在哪里。
|
||||
|
||||
• 浏览笔记本、笔记、标签和附件
|
||||
• 即时搜索本机笔记镜像
|
||||
• 网络不稳定时也能创建和编辑笔记
|
||||
• 在后台把排队中的改动同步到自托管工作区
|
||||
• 用嵌套笔记本和标签整理内容
|
||||
• 添加图片和文件附件
|
||||
• 查看笔记历史并管理工作区
|
||||
• 使用与 EdgeEver 网页端一致的简洁移动界面
|
||||
|
||||
EdgeEver 不含广告、分析、追踪或遥测。应用直接连接到你选择的实例;项目维护者不运营接收或存储笔记的中转服务。
|
||||
|
||||
使用前需要自托管的 EdgeEver 实例。请访问 edgeever.org 了解详情并部署自己的工作区。
|
||||
@@ -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/");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<SupportedLocale, string> = {
|
||||
"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;
|
||||
@@ -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<React.PropsWithChildren,
|
||||
|
||||
render() {
|
||||
if (!this.state.failed) return this.props.children;
|
||||
const english = navigator.language.toLowerCase().startsWith("en");
|
||||
const english = (getBrowserLocale() ?? defaultLocale) === "en-US";
|
||||
return (
|
||||
<main className="mobile-editor-fatal" role="alert">
|
||||
<section className="mobile-editor-fatal-card">
|
||||
|
||||
@@ -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.
|
||||
@@ -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 失败。
|
||||
@@ -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",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AiPromptParameterKind,
|
||||
AiPromptResultMode,
|
||||
} from "./ai-assistant";
|
||||
import { resolveSupportedLocale } from "./i18n/locales";
|
||||
|
||||
export type AiPromptSeedKey = Exclude<AiAction, "custom">;
|
||||
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,
|
||||
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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] : []);
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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\")");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user