mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat(i18n): add Korean as a seventh end-to-end locale
Register `ko` in the locale list, language names, and labels, and ship messages/ko.json so the whole UI — dashboard, settings, share views, clinician PDF, notifications, and AI prompts — renders in Korean. Extend the locale-parity, English-leak, plural-completeness, and copy guard tests to cover the new locale, and update README/CONTRIBUTING to list Korean among the supported languages.
This commit is contained in:
1 parent
3ce5411d19
commit
f1b0420479
50 files changed
+10646
-46
No files matched your search
+1
-1
@@ -121,7 +121,7 @@ Contributors: branch off `main`; PRs target `main`.
|
||||
|
||||
## Translations
|
||||
|
||||
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), and Polish (`pl`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
|
||||
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), and Korean (`ko`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
|
||||
|
||||
### Improving an existing locale
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
|
||||
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so nothing leaves your network.
|
||||
- **Clinician-ready output.** A doctor-report PDF generated client-side, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
|
||||
- **Private by construction.** AES-256-GCM encryption at rest with zero-downtime key rotation, passkey login, server-side sessions, strict CSP — and no third-party tracking anywhere.
|
||||
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, and Polish end to end.
|
||||
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, and Korean end to end.
|
||||
|
||||
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
|
||||
|
||||
|
||||
+10526
File diff suppressed because it is too large.
Load diff
@@ -28,7 +28,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = join(__dirname, "..");
|
||||
const outDir = join(repoRoot, "public", "i18n");
|
||||
|
||||
const LOCALES = ["de", "en", "fr", "es", "it", "pl"];
|
||||
const LOCALES = ["de", "en", "fr", "es", "it", "pl", "ko"];
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"];
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"];
|
||||
const file = (loc) => `messages/${loc}.json`;
|
||||
|
||||
function read(loc) {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"];
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"];
|
||||
const BOUNDARY = /(?<=[.!?])\s+(?=[A-ZÄÖÜÀÂÉÈÊÎÔÙÛÇŁŚŹŻĄĆĘŃÓ0-9„"„«])/u;
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,7 +40,7 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const MESSAGES = join(__dirname, "../../messages");
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
function bundle(locale: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
|
||||
|
||||
@@ -33,7 +33,7 @@ const EN_PATH = join(MESSAGES_DIR, "en.json");
|
||||
// intentionally excluded — it carries accepted anglicisms (e.g. "Health
|
||||
// Score") that would be noise here, and is covered by its own pinned checks in
|
||||
// `i18n-locale-integrity`.
|
||||
const LEAK_LOCALES = ["es", "fr", "it", "pl"] as const;
|
||||
const LEAK_LOCALES = ["es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
function flattenValues(
|
||||
obj: unknown,
|
||||
|
||||
@@ -20,7 +20,7 @@ import { join } from "node:path";
|
||||
* the key is a prop, not a call argument. Both halves are asserted here.
|
||||
*/
|
||||
|
||||
const LOCALES = ["en", "de", "es", "fr", "it", "pl"] as const;
|
||||
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko"] as const;
|
||||
const INSIGHTS_PAGES_DIR = join(process.cwd(), "src/app/insights");
|
||||
|
||||
/**
|
||||
|
||||
@@ -93,7 +93,7 @@ const NOT_IN_THE_FEED = [
|
||||
/** What an action with no sentence of its own renders as. */
|
||||
const FALLBACK_KEY = "recordSharing.activity.acted";
|
||||
/** The six bundles the product ships. */
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
/** Every `route.ts` under `src/app/api`, walked rather than globbed: a glob's
|
||||
* `*` skips a leading dot and this tree has dot-directories. */
|
||||
|
||||
@@ -25,7 +25,7 @@ import { INTEGRATION_DISPLAY_NAMES } from "@/components/settings/integrations/in
|
||||
|
||||
const ROOT = path.resolve(__dirname, "../..");
|
||||
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
/** Every path the retired bridge occupied. */
|
||||
const REMOVED_PATHS = [
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("managed record settings UI", () => {
|
||||
fromRoot("src/components/settings/record-settings-section-gate.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
...["de", "en", "es", "fr", "it", "pl"].map((locale) =>
|
||||
...["de", "en", "es", "fr", "it", "pl", "ko"].map((locale) =>
|
||||
readFile(fromRoot(`messages/${locale}.json`), "utf8"),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
* it — which is the part that rots silently.
|
||||
*/
|
||||
const ROOT = process.cwd();
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
/** The published description of the level, off the Zod schema itself. */
|
||||
function accessDescription(): string {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = join(__dirname, "../..");
|
||||
const LOCALES = ["en", "de", "es", "fr", "it", "pl"] as const;
|
||||
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
const REQUIRED_KEYS = [
|
||||
// Security: adding a passkey now requires an existing sign-in factor.
|
||||
|
||||
@@ -10,7 +10,7 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const LOCALES = ["en", "de", "fr", "es", "it", "pl"] as const;
|
||||
const LOCALES = ["en", "de", "fr", "es", "it", "pl", "ko"] as const;
|
||||
|
||||
interface Messages {
|
||||
notFound: {
|
||||
|
||||
@@ -69,7 +69,8 @@ function isLocale(value: string | null | undefined): value is Locale {
|
||||
value === "fr" ||
|
||||
value === "es" ||
|
||||
value === "it" ||
|
||||
value === "pl"
|
||||
value === "pl" ||
|
||||
value === "ko"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ const MAX_OUTPUT_TOKENS = 600;
|
||||
const requestSchema = z.object({
|
||||
text: z.string().min(1).max(MAX_TEXT_LENGTH),
|
||||
/** Optional UI locale; informational hint for the model. */
|
||||
locale: z.enum(["en", "de", "es", "fr", "it", "pl"]).optional(),
|
||||
locale: z.enum(["en", "de", "es", "fr", "it", "pl", "ko"]).optional(),
|
||||
/**
|
||||
* Optional override of the reference date used to resolve relative
|
||||
* phrases ("tomorrow", "next Monday"). Defaults to the server's
|
||||
|
||||
@@ -32,7 +32,8 @@ function isLocale(value: string | null | undefined): value is Locale {
|
||||
value === "fr" ||
|
||||
value === "es" ||
|
||||
value === "it" ||
|
||||
value === "pl"
|
||||
value === "pl" ||
|
||||
value === "ko"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ function isLocale(value: string | null | undefined): value is Locale {
|
||||
value === "fr" ||
|
||||
value === "es" ||
|
||||
value === "it" ||
|
||||
value === "pl"
|
||||
value === "pl" ||
|
||||
value === "ko"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ describe("dashboard.*Short keys exist across all six locales", () => {
|
||||
|
||||
// Resolved at test time so the assertion runs against the current
|
||||
// bundle, not a stale cached snapshot.
|
||||
const locales = ["en", "de", "fr", "es", "it", "pl"] as const;
|
||||
const locales = ["en", "de", "fr", "es", "it", "pl", "ko"] as const;
|
||||
|
||||
it.each(locales)("%s carries every dashboard.*Short key", async (locale) => {
|
||||
const messages = (await import(`../../../../messages/${locale}.json`))
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* but the prose has not been hand-reviewed for register, regional
|
||||
* vocabulary, or domain-specific nuance.
|
||||
*
|
||||
* This banner renders ONLY on non-maintained locales (FR/ES/IT/PL
|
||||
* This banner renders ONLY on non-maintained locales (FR/ES/IT/PL/KO
|
||||
* today) and surfaces a small, dismissible strip at the top of the
|
||||
* authenticated app shell. It tells the user the translation is
|
||||
* AI-maintained and links to the GitHub translation-feedback issue
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("<OfflineBanner>", () => {
|
||||
});
|
||||
|
||||
it("ships the message key in every supported locale", () => {
|
||||
for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) {
|
||||
for (const locale of ["en", "de", "fr", "es", "it", "pl", "ko"] as const) {
|
||||
const m = loadMessages(locale);
|
||||
expect(m.offlineBanner.message.length, locale).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export interface NaturalLanguageExtractorProps {
|
||||
* UI locale forwarded to the route as a hint. The route accepts an
|
||||
* undefined value and falls back to the resolver chain.
|
||||
*/
|
||||
locale?: "en" | "de" | "es" | "fr" | "it" | "pl";
|
||||
locale?: "en" | "de" | "es" | "fr" | "it" | "pl" | "ko";
|
||||
/**
|
||||
* Optional override of the API path so the wizard test harness can
|
||||
* point at a mock server. Defaults to the production route.
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("<ImportPanel> — SSR smoke", () => {
|
||||
});
|
||||
|
||||
describe("<AppleHealthEstimateWarning>", () => {
|
||||
it.each(["en", "de", "es", "fr", "it", "pl"] as const)(
|
||||
it.each(["en", "de", "es", "fr", "it", "pl", "ko"] as const)(
|
||||
"renders localized estimate disclosure for %s",
|
||||
(locale) => {
|
||||
const html = render(<AppleHealthEstimateWarning days={3} />, locale);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ALL_METRICS } from "@/lib/validations/thresholds";
|
||||
|
||||
import { ManagedRecordSettingsForm } from "../managed-record-settings-section";
|
||||
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
const render = (
|
||||
family: Parameters<typeof ManagedRecordSettingsForm>[0]["family"],
|
||||
|
||||
@@ -218,7 +218,7 @@ describe("<AppleHealthCard>", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["en", "de", "es", "fr", "it", "pl"] as const)(
|
||||
it.each(["en", "de", "es", "fr", "it", "pl", "ko"] as const)(
|
||||
"renders localized Apple Health copy for %s without leaking keys",
|
||||
(locale) => {
|
||||
statusPayload = {
|
||||
|
||||
@@ -400,7 +400,7 @@ function ProfileSettingsForm({
|
||||
<option value="">
|
||||
{t("settings.sharedRecord.managedSettings.profile.systemDefault")}
|
||||
</option>
|
||||
{["de", "en", "es", "fr", "it", "pl"].map((locale) => (
|
||||
{["de", "en", "es", "fr", "it", "pl", "ko"].map((locale) => (
|
||||
<option key={locale} value={locale}>
|
||||
{locale}
|
||||
</option>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import type { Matcher } from "react-day-picker";
|
||||
import { de, enUS, es, fr, it, pl } from "date-fns/locale";
|
||||
import { de, enUS, es, fr, it, ko, pl } from "date-fns/locale";
|
||||
import type { Locale as DateFnsLocale } from "date-fns";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -325,6 +325,8 @@ function resolveDateFnsLocale(
|
||||
return it;
|
||||
case "pl":
|
||||
return pl;
|
||||
case "ko":
|
||||
return ko;
|
||||
default:
|
||||
return enUS;
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ describe("offline-geo runtime detection (v1.4.27 R5)", () => {
|
||||
// issue #659 — the message used to tell a self-hoster to set
|
||||
// MAXMIND_LICENSE_KEY in this repository's Actions secrets, which is
|
||||
// not something they can do. Every locale must name the mount instead.
|
||||
const locales = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
const locales = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
for (const locale of locales) {
|
||||
const bundle = JSON.parse(
|
||||
readFileSync(
|
||||
|
||||
@@ -20,11 +20,11 @@ import type { Locale } from "@/lib/i18n/config";
|
||||
* - grounding: the literal "SNAPSHOT" token, which the grounding rule's
|
||||
* `must_contain` pins in every locale.
|
||||
* - GLP-1 dose safety: the brand registry (Mounjaro / Ozempic), which is
|
||||
* identical across all six locales (an international registry, never
|
||||
* identical across all seven locales (an international registry, never
|
||||
* translated) and only ever appears inside the dose-safety contract.
|
||||
*/
|
||||
|
||||
const ALL_LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl"];
|
||||
const ALL_LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl", "ko"];
|
||||
|
||||
describe("resolved Coach system prompt — grounding + GLP-1 per locale", () => {
|
||||
for (const locale of ALL_LOCALES) {
|
||||
|
||||
@@ -1130,6 +1130,7 @@ const LOCALE_REPLY_FOOTER_FALLBACK: Record<
|
||||
es: "\n\nREPLY LANGUAGE: respond in Spanish. Mirror the user's register; use natural Spanish health vocabulary.",
|
||||
it: "\n\nREPLY LANGUAGE: respond in Italian. Mirror the user's register; use natural Italian health vocabulary.",
|
||||
pl: "\n\nREPLY LANGUAGE: respond in Polish. Mirror the user's register (formal Pan/Pani for medical-adjacent topics); use natural Polish health vocabulary.",
|
||||
ko: "\n\nREPLY LANGUAGE: respond in Korean. Use the 해요체 register throughout (never 합쇼체 or plain 한다체); use natural Korean health vocabulary.",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,8 @@ export function targetLanguageName(locale: Locale): string {
|
||||
return "Italian";
|
||||
case "pl":
|
||||
return "Polish";
|
||||
case "ko":
|
||||
return "Korean";
|
||||
default:
|
||||
return "English";
|
||||
}
|
||||
|
||||
@@ -510,7 +510,7 @@ describe("the scale the popover quotes", () => {
|
||||
).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it.each(["en", "de", "fr", "es", "it", "pl"])(
|
||||
it.each(["en", "de", "fr", "es", "it", "pl", "ko"])(
|
||||
"quotes those same two numbers in %s",
|
||||
(locale) => {
|
||||
const bundle = JSON.parse(
|
||||
|
||||
@@ -33,7 +33,8 @@ function isLocale(value: string | null | undefined): value is Locale {
|
||||
value === "fr" ||
|
||||
value === "es" ||
|
||||
value === "it" ||
|
||||
value === "pl"
|
||||
value === "pl" ||
|
||||
value === "ko"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "@/lib/feature-flags";
|
||||
|
||||
const ROOT = path.resolve(__dirname, "../../../..");
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
/** Names the retired switch went by, on the wire and in the database. */
|
||||
const RETIRED_NAMES = [
|
||||
|
||||
@@ -38,6 +38,7 @@ const INTL_LOCALE_MAP: Record<Locale, string> = {
|
||||
es: "es-ES",
|
||||
it: "it-IT",
|
||||
pl: "pl-PL",
|
||||
ko: "ko-KR",
|
||||
};
|
||||
|
||||
export function resolveIntlLocale(locale: Locale): string {
|
||||
@@ -310,5 +311,6 @@ export function parseLocaleFromAcceptLanguage(header: string | null): Locale {
|
||||
if (primary.startsWith("es")) return "es";
|
||||
if (primary.startsWith("it")) return "it";
|
||||
if (primary.startsWith("pl")) return "pl";
|
||||
if (primary.startsWith("ko")) return "ko";
|
||||
return "en";
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { locales, localeLanguageNames, coerceLocale } from "../config";
|
||||
import { getPhaseMessage } from "@/lib/jobs/reminder-phases";
|
||||
import { detectRefusal } from "@/lib/ai/coach/refusal";
|
||||
|
||||
const NON_GERMAN_TRANSLATED = ["es", "fr", "it", "pl"] as const;
|
||||
const NON_GERMAN_TRANSLATED = ["es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
describe("plural tiers", () => {
|
||||
it("gives Polish its third tier for 2-4 and keeps many for 5+", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const locales = ["de", "en", "fr", "es", "it", "pl"] as const;
|
||||
export const locales = ["de", "en", "fr", "es", "it", "pl", "ko"] as const;
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
export const defaultLocale: Locale = "en";
|
||||
@@ -32,6 +32,7 @@ export const localeLanguageNames: Record<Locale, string> = {
|
||||
es: "Spanish",
|
||||
it: "Italian",
|
||||
pl: "Polish",
|
||||
ko: "Korean",
|
||||
};
|
||||
|
||||
export const localeLabels: Record<Locale, string> = {
|
||||
@@ -41,6 +42,7 @@ export const localeLabels: Record<Locale, string> = {
|
||||
es: "Español",
|
||||
it: "Italiano",
|
||||
pl: "Polski",
|
||||
ko: "한국어",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,6 +49,7 @@ const loaders: Record<Locale, () => Promise<{ default: MessageBundle }>> = {
|
||||
es: () => import("../../../messages/es.json"),
|
||||
it: () => import("../../../messages/it.json"),
|
||||
pl: () => import("../../../messages/pl.json"),
|
||||
ko: () => import("../../../messages/ko.json"),
|
||||
};
|
||||
|
||||
function initialCache(): Partial<Record<Locale, MessageBundle>> {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Since the i18n bundle split, `allMessages` is SERVER-ONLY: the root
|
||||
* layout picks the active locale's bundle out of it for the RSC handoff
|
||||
* and `getServerTranslator()` resolves against it. Do NOT import this
|
||||
* module from client code — the six static JSON imports would drag
|
||||
* module from client code — the seven static JSON imports would drag
|
||||
* every locale back into the client chunk (the ~1.4 MiB regression the
|
||||
* split removed). Client code uses `./load-locale` (static EN fallback
|
||||
* floor + dynamic per-locale imports) and `./resolve-key` instead.
|
||||
@@ -20,6 +20,7 @@ import frMessages from "../../../messages/fr.json";
|
||||
import esMessages from "../../../messages/es.json";
|
||||
import itMessages from "../../../messages/it.json";
|
||||
import plMessages from "../../../messages/pl.json";
|
||||
import koMessages from "../../../messages/ko.json";
|
||||
import { locales, type Locale } from "./config";
|
||||
import { resolveKey } from "./resolve-key";
|
||||
|
||||
@@ -36,6 +37,7 @@ export const allMessages: Record<Locale, Record<string, unknown>> = {
|
||||
es: esMessages,
|
||||
it: itMessages,
|
||||
pl: plMessages,
|
||||
ko: koMessages,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("enqueueSummaryCatchUp", () => {
|
||||
});
|
||||
|
||||
describe("the toggle copy discloses the catch-up", () => {
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
function autoReadCopy(locale: string): { subLabel: string; honesty: string } {
|
||||
const bundle = JSON.parse(
|
||||
|
||||
@@ -163,7 +163,7 @@ describe("enqueueStatusGeneration", () => {
|
||||
const send = vi.fn().mockResolvedValue("job-id");
|
||||
getGlobalBoss.mockReturnValue({ send });
|
||||
|
||||
for (const locale of ["de", "en", "fr", "es", "it", "pl"] as const) {
|
||||
for (const locale of ["de", "en", "fr", "es", "it", "pl", "ko"] as const) {
|
||||
await enqueueStatusGeneration({ userId: "u1", metric: "mood", locale });
|
||||
}
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ describe("buildMoodReminderPayload", () => {
|
||||
expect(p.body).toBe("How are you feeling today?");
|
||||
});
|
||||
|
||||
it.each(["es", "fr", "it", "pl"] as const)(
|
||||
it.each(["es", "fr", "it", "pl", "ko"] as const)(
|
||||
"returns native (non-EN) strings for locale=%s",
|
||||
(locale) => {
|
||||
const p = buildMoodReminderPayload(locale);
|
||||
|
||||
@@ -79,7 +79,8 @@ function isLocale(value: string | null | undefined): value is Locale {
|
||||
value === "fr" ||
|
||||
value === "es" ||
|
||||
value === "it" ||
|
||||
value === "pl"
|
||||
value === "pl" ||
|
||||
value === "ko"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -855,7 +855,7 @@ export const medicationExtractRequest = z
|
||||
"Free-text medication description (any locale). Up to 2 000 characters. The model never echoes the text back into another tenant — it is only used to produce the structured fields.",
|
||||
),
|
||||
locale: z
|
||||
.enum(["en", "de", "es", "fr", "it", "pl"])
|
||||
.enum(["en", "de", "es", "fr", "it", "pl", "ko"])
|
||||
.optional()
|
||||
.describe("Optional UI locale hint for the model."),
|
||||
today: z
|
||||
|
||||
@@ -119,7 +119,7 @@ const profileSettings = z
|
||||
.nullable()
|
||||
.describe("Calendar date (`YYYY-MM-DD`), not an instant."),
|
||||
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]).nullable(),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko"]).nullable(),
|
||||
timezone: z.string(),
|
||||
unitPreference: z.enum(["metric", "imperial"]),
|
||||
timeFormat: z.enum(["AUTO", "H12", "H24"]),
|
||||
|
||||
@@ -59,7 +59,7 @@ const profilePatchSchema = z
|
||||
heightCm: z.number().finite().min(30).max(300).nullable().optional(),
|
||||
dateOfBirth: z.string().date().nullable().optional(),
|
||||
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable().optional(),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]).nullable().optional(),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko"]).nullable().optional(),
|
||||
timezone: timezoneSchema.optional(),
|
||||
unitPreference: z.enum(["metric", "imperial"]).optional(),
|
||||
timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(),
|
||||
|
||||
@@ -19,7 +19,7 @@ export const createManagedProfileSchema = z
|
||||
.object({
|
||||
displayName: z.string().trim().min(1).max(80),
|
||||
dateOfBirth: z.iso.date().nullable().optional(),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko"]),
|
||||
timezone: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
||||
+57
-2
@@ -63,6 +63,55 @@ const suggestionsDe: Record<string, string> = {
|
||||
'Vorhersehbare Ersetzungen wie „@" statt „a" helfen nicht wesentlich',
|
||||
};
|
||||
|
||||
const warningsKo: Record<string, string> = {
|
||||
"Straight rows of keys are easy to guess":
|
||||
"키보드에서 일렬로 놓인 키는 쉽게 추측돼요",
|
||||
"Short keyboard patterns are easy to guess":
|
||||
"짧은 키보드 패턴은 쉽게 추측돼요",
|
||||
'Repeats like "aaa" are easy to guess': '"aaa" 같은 반복은 쉽게 추측돼요',
|
||||
'Repeats like "abcabc" are easy to guess':
|
||||
'"abcabc" 같은 반복은 쉽게 추측돼요',
|
||||
"Sequences like abc or 6543 are easy to guess":
|
||||
"abc나 6543 같은 연속은 쉽게 추측돼요",
|
||||
"Recent years are easy to guess": "최근 연도는 쉽게 추측돼요",
|
||||
"Dates are often easy to guess": "날짜는 대체로 쉽게 추측돼요",
|
||||
"This is a top-10 common password": "가장 흔한 비밀번호 10개 안에 들어요",
|
||||
"This is a top-100 common password": "가장 흔한 비밀번호 100개 안에 들어요",
|
||||
"This is a very common password": "아주 흔한 비밀번호예요",
|
||||
"This is similar to a commonly used password":
|
||||
"자주 쓰이는 비밀번호와 비슷해요",
|
||||
"A word by itself is easy to guess": "단어 하나만으로는 쉽게 추측돼요",
|
||||
"Names and surnames by themselves are easy to guess":
|
||||
"이름이나 성만으로는 쉽게 추측돼요",
|
||||
"Common names and surnames are easy to guess":
|
||||
"흔한 이름과 성은 쉽게 추측돼요",
|
||||
};
|
||||
|
||||
const suggestionsKo: Record<string, string> = {
|
||||
"Use a few words, avoid common phrases":
|
||||
"단어를 여러 개 쓰고, 흔한 문구는 피해 주세요",
|
||||
"No need for symbols, digits, or uppercase letters":
|
||||
"기호, 숫자, 대문자를 꼭 넣지 않아도 돼요",
|
||||
"Add another word or two. Uncommon words are better.":
|
||||
"단어를 한두 개 더 붙여 주세요. 흔하지 않은 단어일수록 좋아요.",
|
||||
"Use a longer keyboard pattern with more turns":
|
||||
"방향이 자주 바뀌는 더 긴 키보드 패턴을 써 주세요",
|
||||
"Avoid repeated words and characters": "반복되는 단어와 문자는 피해 주세요",
|
||||
"Avoid sequences": "연속된 배열은 피해 주세요",
|
||||
"Avoid recent years": "최근 연도는 피해 주세요",
|
||||
"Avoid years that are associated with you":
|
||||
"본인과 관련된 연도는 피해 주세요",
|
||||
"Avoid dates and years that are associated with you":
|
||||
"본인과 관련된 날짜와 연도는 피해 주세요",
|
||||
"Capitalization doesn't help very much": "대문자로 바꿔도 큰 도움이 안 돼요",
|
||||
"All-uppercase is almost as easy to guess as all-lowercase":
|
||||
"전부 대문자로 써도 전부 소문자만큼 쉽게 추측돼요",
|
||||
"Reversed words aren't much harder to guess":
|
||||
"단어를 거꾸로 써도 추측 난이도는 별로 오르지 않아요",
|
||||
"Predictable substitutions like '@' instead of 'a' don't help very much":
|
||||
"'a' 대신 '@'처럼 뻔한 치환은 큰 도움이 안 돼요",
|
||||
};
|
||||
|
||||
// English: identity map for all known strings (the library already returns
|
||||
// English, but we wrap them so unknown values still pass through unchanged).
|
||||
const warningsEn: Record<string, string> = Object.fromEntries(
|
||||
@@ -77,8 +126,14 @@ export interface ZxcvbnTranslations {
|
||||
}
|
||||
|
||||
export function getZxcvbnTranslations(locale: Locale): ZxcvbnTranslations {
|
||||
const warnings = locale === "de" ? warningsDe : warningsEn;
|
||||
const suggestions = locale === "de" ? suggestionsDe : suggestionsEn;
|
||||
const warnings =
|
||||
locale === "de" ? warningsDe : locale === "ko" ? warningsKo : warningsEn;
|
||||
const suggestions =
|
||||
locale === "de"
|
||||
? suggestionsDe
|
||||
: locale === "ko"
|
||||
? suggestionsKo
|
||||
: suggestionsEn;
|
||||
return {
|
||||
translate(text: string): string {
|
||||
return warnings[text] ?? suggestions[text] ?? text;
|
||||
|
||||
@@ -17,6 +17,7 @@ import esMessages from "./messages/es.json";
|
||||
import frMessages from "./messages/fr.json";
|
||||
import itMessages from "./messages/it.json";
|
||||
import plMessages from "./messages/pl.json";
|
||||
import koMessages from "./messages/ko.json";
|
||||
|
||||
primeMessages("de", deMessages);
|
||||
primeMessages("en", enMessages);
|
||||
@@ -24,3 +25,4 @@ primeMessages("es", esMessages);
|
||||
primeMessages("fr", frMessages);
|
||||
primeMessages("it", itMessages);
|
||||
primeMessages("pl", plMessages);
|
||||
primeMessages("ko", koMessages);
|
||||
Reference in new issue
Block a user