mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'pr839tmp' into feat/korean-locale
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
6ff81b2717
74 files changed
+10848
-66
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).
|
||||
|
||||
|
||||
@@ -20248,6 +20248,7 @@ components:
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
practiceName:
|
||||
type: string
|
||||
maxLength: 120
|
||||
@@ -22343,6 +22344,7 @@ components:
|
||||
- fr
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
today:
|
||||
description: "Optional override of the reference date used to resolve relative phrases (\"tomorrow\", \"next Monday\").
|
||||
Format: `YYYY-MM-DD`. Defaults to the server's UTC day."
|
||||
@@ -23234,6 +23236,7 @@ components:
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
description: One of the interface locales this build ships.
|
||||
required:
|
||||
- locale
|
||||
@@ -27714,6 +27717,7 @@ components:
|
||||
- fr
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
timezone:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -27929,6 +27933,7 @@ components:
|
||||
- fr
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- type: "null"
|
||||
timezone:
|
||||
type: string
|
||||
@@ -46021,6 +46026,7 @@ components:
|
||||
- es
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
required:
|
||||
- locale
|
||||
additionalProperties: false
|
||||
@@ -66553,6 +66559,7 @@ components:
|
||||
- fr
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- type: "null"
|
||||
timezone:
|
||||
type: string
|
||||
|
||||
+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"));
|
||||
@@ -78,6 +78,7 @@ const FINALITY_PHRASES: Record<string, readonly string[]> = {
|
||||
"permanente",
|
||||
],
|
||||
pl: ["nie można cofnąć", "nie można tego cofnąć", "trwale", "nieodwracaln"],
|
||||
ko: ["되돌릴 수 없", "복구할 수 없", "영구", "완전히 삭제"],
|
||||
};
|
||||
|
||||
/** Per-locale phrase that promises the short undo window. */
|
||||
@@ -88,6 +89,7 @@ const UNDO_PHRASES: Record<string, string> = {
|
||||
fr: "annuler cette action",
|
||||
it: "annullare",
|
||||
pl: "cofnąć",
|
||||
ko: "되돌릴 수 있",
|
||||
};
|
||||
|
||||
/** Confirmation bodies whose surface renders an Undo affordance. */
|
||||
@@ -171,6 +173,7 @@ describe("sleep debt is labelled as a balance, not a summed deficit", () => {
|
||||
fr: ["cumulé", "cumulée", "déficit"],
|
||||
it: ["cumulato", "cumulata", "accumulato", "deficit"],
|
||||
pl: ["skumulowan", "niedobór", "niedoboru"],
|
||||
ko: ["누적", "부족분", "적자", "쌓인"],
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
@@ -296,6 +299,7 @@ describe("the mood prognosis never states a cause", () => {
|
||||
"peggiora il tuo",
|
||||
],
|
||||
pl: ["ponieważ", "powoduje", "z powodu", "wywołuje", "poprawia twój"],
|
||||
ko: ["때문", "탓", "원인", "유발", "초래", "야기", "덕분", "인해"],
|
||||
};
|
||||
|
||||
/** Every leaf string under `insights.mood.prognosis`, flattened. */
|
||||
@@ -394,6 +398,7 @@ describe("the MCP token copy never claims the mint is read-only", () => {
|
||||
fr: ["jeton en lecture seule"],
|
||||
it: ["token in sola lettura"],
|
||||
pl: ["token tylko do odczytu"],
|
||||
ko: ["읽기 전용 토큰", "읽기전용 토큰"],
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
|
||||
@@ -166,13 +166,14 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => {
|
||||
expect(LOCALES.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("discovers all six shipped locales", () => {
|
||||
it("discovers all seven shipped locales", () => {
|
||||
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"it",
|
||||
"ko",
|
||||
"pl",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -193,6 +193,14 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"mentalHealth.sciOptions.problemDuration.2",
|
||||
"mentalHealth.sciOptions.problemDuration.1",
|
||||
"mentalHealth.sciOptions.problemDuration.0",
|
||||
// Proper nouns, a URL label and an acronym — the accepted written form in
|
||||
// Korean is the Latin original, so these stay identical to en.json.
|
||||
"auth.emailPlaceholder",
|
||||
"settings.fitbitTag",
|
||||
"settings.googleHealthTag",
|
||||
"settings.nightscoutUrl",
|
||||
"admin.aiServerKey.baseUrlLabel",
|
||||
"admin.webPushVapidSubject",
|
||||
]);
|
||||
|
||||
describe("i18n English-leak guard", () => {
|
||||
|
||||
@@ -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 {
|
||||
@@ -109,10 +109,14 @@ describe("the published boundary matches the one the code draws", () => {
|
||||
// accent: the section is "Anamnèse" in French and "Anamneza" in Polish,
|
||||
// so anything past "anamn" is already language-specific and a matcher
|
||||
// tuned to one of them reports the others as broken.
|
||||
if (!/anamn/i.test(text)) missing.push(locale);
|
||||
// Korean shares no Latin stem for either concept, so it is matched on
|
||||
// its own words: "문진" is the anamnesis section, "계정" the account.
|
||||
if (!/anamn|문진/i.test(text)) missing.push(locale);
|
||||
// And it must still say the account itself stays with its owner —
|
||||
// dropping that would fix one over-claim by removing the boundary.
|
||||
expect(text, locale).toMatch(/konto|account|compte|cuenta|konta|koncie/i);
|
||||
expect(text, locale).toMatch(
|
||||
/konto|account|compte|cuenta|konta|koncie|계정/i,
|
||||
);
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -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`))
|
||||
|
||||
@@ -223,9 +223,9 @@ const FULL_RECORD = {
|
||||
const DOTTED_KEY = /\b[a-z][A-Za-z0-9]*(\.[A-Za-z0-9_]+){2,}\b/g;
|
||||
|
||||
describe("<ClinicianView> resolves every label in every locale", () => {
|
||||
it("covers all six shipped locales", () => {
|
||||
it("covers all seven shipped locales", () => {
|
||||
// A floor: an empty locale list would make the loop below assert nothing.
|
||||
expect(locales.length).toBe(6);
|
||||
expect(locales.length).toBe(7);
|
||||
});
|
||||
|
||||
for (const locale of locales) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -41,13 +41,14 @@ const REQUIRED_KEYS = [
|
||||
];
|
||||
|
||||
describe("measurement-reminder + bulk-share dynamic i18n keys", () => {
|
||||
it("discovers all six shipped locales", () => {
|
||||
it("discovers all seven shipped locales", () => {
|
||||
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"it",
|
||||
"ko",
|
||||
"pl",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -276,7 +276,7 @@ describe("the managed-profile card, in the shared-access section", () => {
|
||||
/<select[^>]*data-slot="managed-profile-locale"[\s\S]*?<\/select>/
|
||||
.exec(html)?.[0]
|
||||
.match(/<option/g) ?? [];
|
||||
expect(options).toHaveLength(6);
|
||||
expect(options).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("lists the profiles this account looks after, and only those", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -252,6 +252,7 @@ describe("normative-claim detection across the shipped locales", () => {
|
||||
es: "Su sistólica debería mantenerse por debajo del objetivo de 130 mmHg.",
|
||||
it: "La sistolica dovrebbe restare al di sotto dell’obiettivo di 130 mmHg.",
|
||||
pl: "Ciśnienie skurczowe powinno pozostać poniżej celu 130 mmHg.",
|
||||
ko: "수축기 혈압은 목표인 130 mmHg 아래로 유지해야 해요.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -266,6 +267,7 @@ describe("normative-claim detection across the shipped locales", () => {
|
||||
es: "Su media de 7 días excede en 4 mmHg su mediana de 90 días.",
|
||||
it: "La media a 7 giorni supera di 4 mmHg la mediana a 90 giorni.",
|
||||
pl: "Twoja średnia z 7 dni przekracza medianę z 90 dni o 4 mmHg.",
|
||||
ko: "7일 평균이 90일 중앙값보다 4 mmHg 더 커요.",
|
||||
};
|
||||
|
||||
it.each(locales)("carries a non-empty keyword bank for %s", (locale) => {
|
||||
|
||||
@@ -141,6 +141,17 @@ const NORMATIVE_KEYWORDS: Record<Locale, readonly string[]> = {
|
||||
"powyzej",
|
||||
"ponizej",
|
||||
],
|
||||
ko: [
|
||||
"목표",
|
||||
"권장",
|
||||
"정상 범위",
|
||||
"해야",
|
||||
"하세요",
|
||||
"이상",
|
||||
"이하",
|
||||
"초과",
|
||||
"미만",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -867,6 +867,17 @@ const POPULATION_NORM_PATTERNS: Record<Locale, readonly RegExp[]> = {
|
||||
/\bdoros(?:li|łych)\s+(?:zazwyczaj|zwykle|na\s+ogół)\s+(?:potrzebują|wymagają)\b/i,
|
||||
/\b(?:zakres|przedział)\s+(?:referencyjny|normy|prawidłowy|zalecany)\b/i,
|
||||
/\b(?:wynosi|mieści\s+się)\s+(?:zazwyczaj|zwykle|na\s+ogół)\s+(?:od|między|około)\b/i,
|
||||
], // No `\b` next to Hangul (it is an ASCII \w boundary), and the object sits
|
||||
// between subject and verb in Korean, so each entry allows a gap where the
|
||||
// Latin banks put \s+.
|
||||
ko: [
|
||||
/정상\s*범위(?:는|가|를|로|의)/,
|
||||
/(?:참고|기준|권장)\s*(?:범위|치|값)/,
|
||||
/(?:건강한|대부분의)\s*(?:성인|사람들?|인구)/,
|
||||
/일반\s*인구(?:는|에서|의)/,
|
||||
/(?:지침|가이드라인|의사들?|전문가들?)(?:은|이|에서|에서는|에 따르면)?[^.?!]{0,25}(?:권(?:장|해)|권고|제안|조언)/,
|
||||
/(?:성인|사람들?|대부분의?\s*사람들?)(?:은|이|의)?\s*(?:보통|일반적으로|대개|평균적으로)\s*[^.?!]{0,20}(?:필요|권장|적당)/,
|
||||
/(?:보통|일반적으로|대개)\s*[^.?!]{0,15}(?:사이|정도|범위)(?:예요|에요|입니다|이에요)/,
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1157,6 +1158,9 @@ export function getCoachSystemPrompt(
|
||||
base = COACH_PROMPT_DE;
|
||||
} else if (locale === "en") {
|
||||
base = COACH_PROMPT_EN;
|
||||
} else if (locale === "ko") {
|
||||
// ko ships no safety-contract matrix, so it is not a NativeCoachLocale.
|
||||
base = COACH_PROMPT_EN + LOCALE_REPLY_FOOTER_FALLBACK.ko;
|
||||
} else {
|
||||
try {
|
||||
base = buildNativeCoachPrompt(locale, PROMPT_VERSION);
|
||||
|
||||
@@ -56,6 +56,7 @@ const DRUG_LEVEL_CONCEPT_TOKENS: Record<Locale, readonly string[]> = {
|
||||
es: ["nivel", "concentración", "pico", "valle", "Cmax", "terapéutica"],
|
||||
it: ["livello", "concentrazione", "picco", "valle", "Cmax", "terapeutica"],
|
||||
pl: ["poziom", "stężeni", "szczyt", "minimum", "Cmax", "terapeutyczn"],
|
||||
ko: ["농도", "혈중", "최고", "최저", "Cmax", "치료"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,7 @@ const EXPECTED_LANGUAGE_NAME: Record<Locale, string> = {
|
||||
es: "Spanish",
|
||||
it: "Italian",
|
||||
pl: "Polish",
|
||||
ko: "Korean",
|
||||
};
|
||||
|
||||
/** Which body each locale is expected to compose. */
|
||||
@@ -49,6 +50,7 @@ const EXPECTED_BODY: Record<Locale, "de" | "en"> = {
|
||||
es: "en",
|
||||
it: "en",
|
||||
pl: "en",
|
||||
ko: "en",
|
||||
};
|
||||
|
||||
describe("output-language helper", () => {
|
||||
|
||||
@@ -982,6 +982,7 @@ const INSIGHTS_LOCALE_REPLY_FOOTER_FALLBACK: Record<
|
||||
es: "\n\nREPLY LANGUAGE: render all user-facing strings in Spanish (peninsular preferred). Use natural Spanish health vocabulary. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.",
|
||||
it: "\n\nREPLY LANGUAGE: render all user-facing strings in Italian. Use natural Italian health vocabulary. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.",
|
||||
pl: "\n\nREPLY LANGUAGE: render all user-facing strings in Polish. Use natural Polish health vocabulary with formal Pan/Pani register for medical-adjacent topics. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.",
|
||||
ko: "\n\nREPLY LANGUAGE: render all user-facing strings in Korean, in the 해요체 register throughout (never 합쇼체 or plain 한다체). Use natural Korean health vocabulary. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -991,6 +992,10 @@ const INSIGHTS_LOCALE_REPLY_FOOTER_FALLBACK: Record<
|
||||
export function getStrictInsightsSystemPrompt(locale: Locale): string {
|
||||
if (locale === "de") return SYSTEM_PROMPT_DE;
|
||||
if (locale === "en") return SYSTEM_PROMPT_EN;
|
||||
// ko ships no safety-contract matrix, so it is not a NativeCoachLocale.
|
||||
if (locale === "ko") {
|
||||
return SYSTEM_PROMPT_EN + INSIGHTS_LOCALE_REPLY_FOOTER_FALLBACK.ko;
|
||||
}
|
||||
try {
|
||||
return buildNativeInsightsPrompt(locale, PROMPT_VERSION);
|
||||
} catch {
|
||||
|
||||
@@ -54,6 +54,8 @@ export function targetLanguageName(locale: Locale): string {
|
||||
return "Italian";
|
||||
case "pl":
|
||||
return "Polish";
|
||||
case "ko":
|
||||
return "Korean";
|
||||
default:
|
||||
return "English";
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ const DOSE_VIOLATIONS: Record<Locale, string> = {
|
||||
es: "Aumente su dosis a 2,4 mg la próxima semana.",
|
||||
it: "Aumenti la sua dose a 2,4 mg la prossima settimana.",
|
||||
pl: "Proszę zwiększyć dawkę do 2,4 mg w przyszłym tygodniu.",
|
||||
ko: "다음 주에 2.4mg으로 늘려 보세요.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -37,6 +38,7 @@ const DOSE_CLEAN: Record<Locale, string> = {
|
||||
es: "Está en la semana 3 con 7,5 mg, y su peso está bajando.",
|
||||
it: "È alla settimana 3 con 7,5 mg, e il suo peso sta calando.",
|
||||
pl: "Jest Pan w 3. tygodniu na 7,5 mg, a waga spada.",
|
||||
ko: "3주째 7.5mg을 복용 중이고, 체중은 내려가고 있어요.",
|
||||
};
|
||||
|
||||
describe("screenModelOutput — dose-prescription, all six locales", () => {
|
||||
@@ -72,6 +74,7 @@ const RISK_VIOLATIONS: Record<Locale, string> = {
|
||||
es: "Su riesgo es del 12%.",
|
||||
it: "Il suo rischio è del 12%.",
|
||||
pl: "Pana ryzyko wynosi 12%.",
|
||||
ko: "10년 심혈관 위험도는 약 12%예요.",
|
||||
};
|
||||
|
||||
/** A plain percentage that is NOT a clinical risk claim must pass. */
|
||||
@@ -82,6 +85,7 @@ const RISK_CLEAN: Record<Locale, string> = {
|
||||
es: "Su adherencia fue del 92% en los últimos 30 días.",
|
||||
it: "La sua aderenza è stata del 92% negli ultimi 30 giorni.",
|
||||
pl: "Przestrzeganie zaleceń wyniosło 92% w ostatnich 30 dniach.",
|
||||
ko: "지난 30일 복약 순응도는 92%였어요.",
|
||||
};
|
||||
|
||||
describe("screenModelOutput — risk-score, all six locales", () => {
|
||||
@@ -324,6 +328,7 @@ const CAUSAL_VIOLATIONS: Record<Locale, string> = {
|
||||
es: "Su peso bajó porque durmió más.",
|
||||
it: "Il suo peso è calato perché ha dormito di più.",
|
||||
pl: "Waga spadła, ponieważ spał Pan więcej.",
|
||||
ko: "수면 때문에 체중이 줄었어요.",
|
||||
};
|
||||
|
||||
/** Descriptive association framing is what the contract REQUIRES. */
|
||||
@@ -334,6 +339,7 @@ const CAUSAL_CLEAN: Record<Locale, string> = {
|
||||
es: "Su peso evolucionó junto con su sueño esta semana.",
|
||||
it: "Il suo peso si è mosso insieme al suo sonno questa settimana.",
|
||||
pl: "Waga zmieniała się razem ze snem w tym tygodniu.",
|
||||
ko: "이번 주 체중은 수면과 함께 움직였어요.",
|
||||
};
|
||||
|
||||
describe("screenModelOutput — causal claims, all six locales", () => {
|
||||
|
||||
@@ -202,6 +202,35 @@ const DOSE_PATTERNS: Record<Locale, readonly RegExp[]> = {
|
||||
`\\b(?:nast[ęe]pna|kolejna|nowa)\\s+dawka\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`,
|
||||
"i",
|
||||
),
|
||||
], // Korean is verb-final: the dose comes first and the change verb closes the
|
||||
// clause ("2.4mg으로 늘려 보세요"), the mirror image of the Latin banks. A
|
||||
// target-marking particle on the dose is required, the same demand the Latin
|
||||
// banks make of "to" / "by" / "auf" — without it the permitted restatement
|
||||
// "7.5mg을 복용 중이고, 체중은 내려가고 있어요" matched the lowering pattern.
|
||||
ko: [
|
||||
new RegExp(
|
||||
`[\\d.,]+\\s*${DOSE_UNIT}\\s*(?:으로|로|까지)[^.?!]{0,20}(?:올리|올려|늘리|늘려|증량|높이|높여|인상)`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`[\\d.,]+\\s*${DOSE_UNIT}\\s*(?:으로|로|까지)[^.?!]{0,20}(?:내리|내려|줄이|줄여|감량|낮추|낮춰|감축)`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?:권해|권장|권고|추천|제안)[^.?!]{0,40}[\\d.,]+\\s*${DOSE_UNIT}\\b`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`[\\d.,]+\\s*${DOSE_UNIT}\\b[^.?!]{0,40}(?:권해|권장|권고|추천|제안|드심|드셔|복용하세요|시도해)`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?:다음|새)\\s*(?:용량|단계|복용량)[^.?!]{0,30}[\\d.,]+\\s*${DOSE_UNIT}\\b`,
|
||||
"i",
|
||||
),
|
||||
// Both a medication object AND a trial cue are required, as in the EN bank,
|
||||
// so "걸음 수를 두 배로 늘려 2주간 해보세요" never trips.
|
||||
/(?:약|알|정|인슐린|주사|복용|용량)[^.?!]{0,25}(?:절반|반으로|두\s?배|건너뛰|중단|끊)[^.?!]{0,40}(?:주간|주 동안|일 동안|달 동안|보세요|보십시오|보자|해 ?보)/,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -301,6 +330,20 @@ const RISK_NOUN_PL = "(?:ryzyk\\w*|prawdopodobieństw\\w*)";
|
||||
const RESULT_VERB_PL =
|
||||
"(?:umieszcza\\s+(?:cię|pana|panią)|klasyfikuje\\s+(?:cię|pana|panią)|znajdujesz\\s+się\\s+w|wpadasz\\s+w|kwalifikuje\\s+(?:cię|pana|panią))";
|
||||
|
||||
// Korean carries no `\\b` next to a Hangul token: `\\b` is an ASCII \\w boundary
|
||||
// and Hangul is not \\w, so /\\b위험/ matches nothing. Spelled-out percentages are
|
||||
// omitted deliberately — Korean writes "12%", and the Sino-Korean numerals
|
||||
// ("이", "삼") collide with common particles.
|
||||
const QUAL_NUM_KO = "(?:\\d{1,3}\\s*(?:%|퍼센트|프로))";
|
||||
const RISK_NOUN_KO = "(?:위험(?:도|률|성)?|확률|가능성)";
|
||||
const HORIZON_KO = "(?:10년|십\\s?년|평생)\\s*(?:위험(?:도|률)?|리스크)";
|
||||
const RESULT_VERB_KO =
|
||||
"(?:(?:에|으로|로)\\s*(?:분류|해당|속하|들어가)|군에\\s*(?:속하|해당))";
|
||||
const RISK_BAND_KO =
|
||||
"(?:고|중간|중등도|저|높은|낮은)\\s*위험\\s*(?:군|범위|등급|구간)";
|
||||
const RISK_LEVEL_KO = "(?:높|상승|증가|중등도|경계|우려)";
|
||||
const RISK_VERDICT_KO = `(?:은|는|이|가)\\s*[^.?!]{0,10}${RISK_LEVEL_KO}`;
|
||||
|
||||
const RISK_PATTERNS: Record<Locale, readonly RegExp[]> = {
|
||||
en: [
|
||||
// (1) digit percent adjacent to a risk noun, either order
|
||||
@@ -413,6 +456,26 @@ const RISK_PATTERNS: Record<Locale, readonly RegExp[]> = {
|
||||
new RegExp(`${QUAL_NUM_PL}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"),
|
||||
new RegExp(`\\b${ENGINE}\\b[^.?!]{0,60}${RESULT_VERB_PL}`, "i"),
|
||||
],
|
||||
ko: [
|
||||
new RegExp(`${RISK_NOUN_KO}[^.?!]{0,15}${QUAL_NUM_KO}`, "i"),
|
||||
new RegExp(`${QUAL_NUM_KO}[^.?!]{0,15}${RISK_NOUN_KO}`, "i"),
|
||||
new RegExp(`${HORIZON_KO}[^.?!]{0,40}${QUAL_NUM_KO}`, "i"),
|
||||
new RegExp(`${QUAL_NUM_KO}[^.?!]{0,40}${HORIZON_KO}`, "i"),
|
||||
new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_KO}`, "i"),
|
||||
new RegExp(`${QUAL_NUM_KO}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"),
|
||||
new RegExp(
|
||||
`(?:\\b${ENGINE}\\b|${HORIZON_KO})[^.?!]{0,60}${RESULT_VERB_KO}`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?:\\b${ENGINE}\\b|${HORIZON_KO})[^.?!]{0,60}${RISK_BAND_KO}`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?:\\b${ENGINE}\\b|${HORIZON_KO})[^.?!]{0,40}${RISK_VERDICT_KO}`,
|
||||
"i",
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -492,6 +555,18 @@ const CAUSAL_PATTERNS: Record<Locale, readonly RegExp[]> = {
|
||||
/\bdzi[ęe]ki\b/i,
|
||||
/\bodpowiedzialn\w*\s+za\b/i,
|
||||
],
|
||||
ko: [
|
||||
/때문(?:에|이)/,
|
||||
/(?:으로|로)\s*인(?:해|하여|한)/,
|
||||
/탓(?:에|이|으로)/,
|
||||
/(?:이|가)\s*원인/,
|
||||
/원인(?:은|이에요|입니다|이라|으로)/,
|
||||
/유발/,
|
||||
/초래/,
|
||||
/야기/,
|
||||
/덕분(?:에|이)/,
|
||||
/(?:영향을|결과로)\s*(?:줘서|줬|나타났|이어졌)/,
|
||||
],
|
||||
};
|
||||
|
||||
const BANKS: Record<OutboundContract, Record<Locale, readonly RegExp[]>> = {
|
||||
@@ -542,6 +617,7 @@ const DOSE_CONTINUATION: Record<Locale, RegExp> = {
|
||||
es: /\b(?:siga\s+(?:tomando|con)|continúe|mantenga|según\s+lo\s+prescrito)\b/i,
|
||||
it: /\b(?:continui\s+(?:a|con|il)|mantenga|come\s+prescritto)\b/i,
|
||||
pl: /\b(?:kontynuuj|przyjmuj\s+dalej|zgodnie\s+z\s+zaleceniem|pozosta[ńn]\s+przy)\b/i,
|
||||
ko: /(?:그대로\s*(?:유지|복용)|계속\s*(?:복용|드시|유지)|처방(?:대로|받은\s*대로)|지시(?:대로|받은\s*대로)|유지하세요)/,
|
||||
};
|
||||
|
||||
const DOSE_CHANGE_STEM: Record<Locale, RegExp> = {
|
||||
@@ -551,6 +627,7 @@ const DOSE_CHANGE_STEM: Record<Locale, RegExp> = {
|
||||
es: /\b(?:aument|reduzc|reduc|baj|disminu|dobl|omit|salt)\w*/i,
|
||||
it: /\b(?:aument|riduc|ridur|abbass|diminu|raddoppi|salt|dimezz)\w*/i,
|
||||
pl: /\b(?:zwiększ|zmniejsz|obniż|podnie[śs]|podwyższ|zreduk|pomi[ńn]|opu[śs][ćc])\w*/i,
|
||||
ko: /(?:올리|올려|늘리|늘려|증량|높이|높여|인상|내리|내려|줄이|줄여|감량|낮추|낮춰|감축|절반|반으로|두\s?배|건너뛰|중단|끊)/,
|
||||
};
|
||||
|
||||
/** Sentence-level split — the dose exemption must be scoped to one sentence. */
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("runDocumentSummary — screened prose", () => {
|
||||
es: "Este informe sugiere aumentar su dosis a 2,4 mg la próxima semana.",
|
||||
it: "Questo referto suggerisce di aumentare la sua dose a 2,4 mg la prossima settimana.",
|
||||
pl: "Ten raport sugeruje, aby zwiększyć dawkę do 2,4 mg w przyszłym tygodniu.",
|
||||
ko: "이 보고서는 다음 주에 2.4mg으로 늘려 보라고 권해요.",
|
||||
};
|
||||
|
||||
for (const locale of locales) {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -62,7 +62,10 @@ export interface LabLoincMapping {
|
||||
* resolved before resolves to the same key now.
|
||||
*/
|
||||
export function normaliseLabKey(s: string): string {
|
||||
return stripDiacritics(s.toLowerCase()).replace(/[^a-z0-9]/g, "");
|
||||
// Hangul syllables are kept alongside the ASCII range: stripping to
|
||||
// [a-z0-9] alone reduced every Korean analyte name to the empty string, so
|
||||
// `buildLocalisedAliases` indexed none of them and no Korean label resolved.
|
||||
return stripDiacritics(s.toLowerCase()).replace(/[^a-z0-9가-힣]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: "한국어",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,7 +51,12 @@ const COMBINING_MARKS = /[\u0300-\u036f]/gu;
|
||||
* printed range's "3,5-5,0", a unit's "mg/dL".
|
||||
*/
|
||||
export function stripDiacritics(raw: string): string {
|
||||
return raw.normalize("NFD").replace(COMBINING_MARKS, "");
|
||||
// Recomposed with NFC after the strip because NFD splits Hangul into JAMO,
|
||||
// and jamo are not in the COMBINING_MARKS range, so they survive: without
|
||||
// this the folded form of "목표" is a jamo sequence that no precomposed
|
||||
// literal in any table can match. Latin/Polish/German folds are unaffected —
|
||||
// the combining marks are already gone, so there is nothing to recompose.
|
||||
return raw.normalize("NFD").replace(COMBINING_MARKS, "").normalize("NFC");
|
||||
}
|
||||
|
||||
/** Collapse every apostrophe variant onto the straight ASCII one. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,7 @@ const DOSE_VIOLATION: Record<string, string> = {
|
||||
es: '{"summary":"Aumente su dosis a 2,4 mg la próxima semana."}',
|
||||
it: '{"summary":"Aumenti la sua dose a 2,4 mg la prossima settimana."}',
|
||||
pl: '{"summary":"Proszę zwiększyć dawkę do 2,4 mg w przyszłym tygodniu."}',
|
||||
ko: '{"summary":"다음 주에 2.4mg으로 늘려 보세요."}',
|
||||
};
|
||||
|
||||
const CAUSAL_VIOLATION: Record<string, string> = {
|
||||
@@ -30,6 +31,7 @@ const CAUSAL_VIOLATION: Record<string, string> = {
|
||||
es: '{"summary":"Su peso bajó porque durmió más."}',
|
||||
it: '{"summary":"Il suo peso è calato perché ha dormito di più."}',
|
||||
pl: '{"summary":"Waga spadła, ponieważ spał Pan więcej."}',
|
||||
ko: '{"summary":"수면 때문에 체중이 줄었어요."}',
|
||||
};
|
||||
|
||||
const CLEAN: Record<string, string> = {
|
||||
@@ -39,6 +41,7 @@ const CLEAN: Record<string, string> = {
|
||||
es: '{"summary":"Su peso promedia 82 kg esta semana, estable frente al mes pasado."}',
|
||||
it: '{"summary":"Il suo peso è in media di 82 kg questa settimana, stabile rispetto al mese scorso."}',
|
||||
pl: '{"summary":"Waga wynosi średnio 82 kg w tym tygodniu, stabilnie wobec zeszłego miesiąca."}',
|
||||
ko: '{"summary":"이번 주 체중은 평균 82kg으로, 지난달과 비슷해요."}',
|
||||
};
|
||||
|
||||
describe("finalizeStatusSummary — dose prescriptions are withheld", () => {
|
||||
|
||||
@@ -249,6 +249,7 @@ describe("tone harness — deterministic fallbacks", () => {
|
||||
es: /no hay ninguna valoración/,
|
||||
it: /non è disponibile alcuna valutazione/,
|
||||
pl: /nie ma (?:tu )?żadnej oceny/,
|
||||
ko: /평가가 없어요/,
|
||||
};
|
||||
|
||||
describe.each(FLOORS)("$name floor", ({ build }) => {
|
||||
|
||||
@@ -479,6 +479,7 @@ const NO_READ_LEAD: Localized<string> = {
|
||||
es: "Ahora mismo no hay ninguna valoración disponible.",
|
||||
it: "Al momento non è disponibile alcuna valutazione.",
|
||||
pl: "Na razie nie ma tu żadnej oceny.",
|
||||
ko: "지금은 이 카드에 대한 평가가 없어요.",
|
||||
};
|
||||
|
||||
function noReadLead(locale: InsightLocale): string {
|
||||
@@ -520,6 +521,7 @@ const BIOMARKER_NAMED_LEAD: Localized<(named: string) => string> = {
|
||||
es: (n) => `Ahora mismo no hay ninguna valoración${n}.`,
|
||||
it: (n) => `Al momento non è disponibile alcuna valutazione${n}.`,
|
||||
pl: (n) => `Na razie nie ma żadnej oceny${n}.`,
|
||||
ko: (n) => `지금은${n} 평가가 없어요.`,
|
||||
};
|
||||
|
||||
const BIOMARKER_NAMED_CLAUSE: Localized<(marker: string) => string> = {
|
||||
@@ -529,6 +531,7 @@ const BIOMARKER_NAMED_CLAUSE: Localized<(marker: string) => string> = {
|
||||
es: (m) => ` de «${m}»`,
|
||||
it: (m) => ` per «${m}»`,
|
||||
pl: (m) => ` dla „${m}”`,
|
||||
ko: (m) => ` '${m}'에 대한`,
|
||||
};
|
||||
|
||||
export function getNoKeyBiomarkerStatusText(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -259,6 +259,15 @@ export const RANGE_PROSE: Record<Locale, RangeProse> = {
|
||||
"wartosci referencyjne",
|
||||
"normy laboratoryjne",
|
||||
],
|
||||
}, // Korean marks a bound with a POSTPOSITION — "5.0 이하", "3.5 이상" — and the
|
||||
// matchers here are prefix-anchored, so only the prefix-shaped notation is
|
||||
// listed. A postfixed Korean range still falls through to text-only, which
|
||||
// `isUnreadableRange` reports rather than hides.
|
||||
ko: {
|
||||
twoSided: ["에서", "부터"],
|
||||
upper: ["최대", "최고"],
|
||||
lower: ["최소", "최저"],
|
||||
filler: ["참고치", "참고범위", "정상치", "정상범위", "기준치", "기준범위"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -291,7 +300,10 @@ function proseAlternation(field: keyof RangeProse): string {
|
||||
* "e" and "y" are one letter long, and a bare `e` that could sit between two
|
||||
* digits would read "3.5e5" as a window.
|
||||
*/
|
||||
const SYMBOL_SEP = String.raw`-|–|—|‒|…|\.{2,3}`;
|
||||
// U+007E and the fullwidth U+FF5E are the separator a Korean lab prints
|
||||
// ("3.5~5.0"); both are added here rather than to `twoSided` because they are
|
||||
// symbols, and the symbol branch needs no surrounding whitespace.
|
||||
const SYMBOL_SEP = String.raw`-|–|—|‒|~|~|…|\.{2,3}`;
|
||||
|
||||
/** Prefixes that state a ceiling only. */
|
||||
const UPPER_PREFIX = `(?:<=|<|≤|${proseAlternation("upper")})`;
|
||||
|
||||
@@ -337,9 +337,9 @@ describe("resolveRichMetric — localised metric names", () => {
|
||||
expect(partial.sort()).toEqual([
|
||||
"BODY_TEMPERATURE_DEVIATION: en",
|
||||
"BREATHING_DISTURBANCE_EVENT: en",
|
||||
"FAT_FREE_MASS: de,en,es",
|
||||
"FAT_MASS: de,en,es,pl",
|
||||
"LEAN_BODY_MASS: de,en,es",
|
||||
"FAT_FREE_MASS: de,en,es,ko",
|
||||
"FAT_MASS: de,en,es,pl,ko",
|
||||
"LEAN_BODY_MASS: de,en,es,ko",
|
||||
"WALKING_STEADINESS_EVENT: en",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -196,7 +196,7 @@ describe("mental-health instruments", () => {
|
||||
});
|
||||
|
||||
it("declares validated item locales honestly (SCI = English only)", () => {
|
||||
for (const locale of ["de", "en", "es", "fr", "it", "pl"]) {
|
||||
for (const locale of ["de", "en", "es", "fr", "it", "pl", "ko"]) {
|
||||
expect(hasValidatedItems("WHO5", locale)).toBe(true);
|
||||
expect(hasValidatedItems("PHQ9", locale)).toBe(true);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
* - PHQ-9: Kroenke 2001; GAD-7: Spitzer 2006 (public-domain Pfizer grant).
|
||||
* - WHO-5: WHO/UCN/MSD/MHE/2024.1 (© WHO 2024, CC BY-NC-SA 3.0 IGO) — raw
|
||||
* 0–25, × 4 → 0–100; "a percentage score below 50 … an indication for
|
||||
* further assessment". WHO publishes official translations for all six
|
||||
* app locales; they ship verbatim.
|
||||
* further assessment". Six app locales ship the WHO/PRU translations
|
||||
* verbatim; Korean, which neither distribution carries, ships the
|
||||
* published Kim 2010 translation instead.
|
||||
* - SCI: Espie et al., BMJ Open 2014;4:e004183 (© Sleepio Ltd, licensed
|
||||
* free for non-commercial use, CC BY-NC) — 8 items, 0–4 each, total 0–32,
|
||||
* HIGHER = better sleep; "a score of ≤16 … seems reasonable to detect
|
||||
@@ -98,7 +99,7 @@ export interface InstrumentDefinition {
|
||||
attribution: string;
|
||||
}
|
||||
|
||||
const ALL_APP_LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
const ALL_APP_LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
/**
|
||||
* The PHQ family is public-domain (Pfizer 2010); this is the canonical footer
|
||||
@@ -188,11 +189,17 @@ const WHO5: InstrumentDefinition = {
|
||||
actionThreshold: 50,
|
||||
safetyItemIndex: null,
|
||||
// WHO publishes official de/en/es/fr/it/pl versions — all bundled verbatim.
|
||||
// There is no Korean one, so `ko` carries the published forward/back-
|
||||
// translation from Kim 2010 instead, which is CC BY-NC and so
|
||||
// redistributable. Its validation sample was community-dwelling elderly,
|
||||
// not general adults — narrower than the other six arms.
|
||||
validatedItemLocales: ALL_APP_LOCALES,
|
||||
attribution:
|
||||
"World Health Organization. The World Health Organization-Five Well-Being " +
|
||||
"Index (WHO-5). Geneva: World Health Organization; 2024. Licence: " +
|
||||
"CC BY-NC-SA 3.0 IGO. WHO does not endorse this application.",
|
||||
"CC BY-NC-SA 3.0 IGO. WHO does not endorse this application. Korean " +
|
||||
"translation: Kim HJ, Moon YS, Son BK, Lee SK, Rho HJ, Kim DH. J Korean " +
|
||||
"Geriatr Psychiatry 2010;14(2):90-96 (CC BY-NC 3.0).",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -73,10 +73,10 @@ const VALUE_GROUPS: Array<{ group: string; keys: readonly string[] }> = [
|
||||
];
|
||||
|
||||
describe("mood context vocabulary labels", () => {
|
||||
it("reads six locales and a non-empty vocabulary", () => {
|
||||
it("reads seven locales and a non-empty vocabulary", () => {
|
||||
// Both ends have to be there before anything below proves anything. A
|
||||
// guard that walks an empty list is a green light nobody earned.
|
||||
expect(LOCALES.length).toBe(6);
|
||||
expect(LOCALES.length).toBe(7);
|
||||
expect(VALUE_GROUPS.length).toBeGreaterThan(0);
|
||||
const total = VALUE_GROUPS.reduce((n, g) => n + g.keys.length, 0);
|
||||
expect(total).toBeGreaterThan(30);
|
||||
|
||||
@@ -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,10 @@ 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