mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 03:55:38 -04:00
feat: add i18n with LingUI, Crowdin integration, and typed error codes (#16)
* feat: add i18n with LingUI, Crowdin integration, and typed error codes - Add `@sofa/i18n` shared package with LingUI v5 (v6-ready config using `defineConfig` + `@lingui/format-po`), eager English + lazy-loaded fr/de/es/it/pt catalogs, `Intl`-based date/number format utilities, and test helpers - Wire `@lingui/vite-plugin` + babel macro plugin for web, and `@lingui/metro-transformer` + babel config for native - Add `I18nProvider` to both app roots with locale auto-detection (navigator.language / expo-localization) and persistence (localStorage / MMKV) - Wrap all ~512 user-facing strings across web and native with LingUI macros (`<Trans>`, `useLingui`/`t`, `i18n._(msg`...`)`, `plural`) - Add language switcher to Settings in both apps - Add `@sofa/api/errors` with 13 typed `AppErrorCode` values and `appErrorData()` helper for contract `.errors()` schemas - Update all oRPC contract error definitions with typed `data` fields; convert import procedure `throw new Error()` to `ORPCError` with codes - Add per-app `error-messages.ts` utilities that map error codes to localized strings; update global `QueryCache.onError` handlers to stop leaking raw `error.message` to users - Add `crowdin.yml` config and `.github/workflows/crowdin.yml` for automated source upload on merge and translation pull via dispatch - Add `lingui.config.ts` at repo root with `bun run i18n:extract` and `bun run i18n:compile` convenience scripts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: add Crowdin language mapping and ignore context file * fix(i18n): address 20 localization quality issues - Stop leaking raw error.message to users in change-password and account-section; use localized fallbacks instead - Add typed error data to discover contract's .errors() schema - Replace all manual English plural suffixes with LingUI plural() macro in episode counts, backup counts, star ratings, title/image counts - Replace date-fns formatDistanceToNow and hardcoded date patterns with locale-aware formatDate/formatRelativeTime from @sofa/i18n/format - Convert integration-configs from module-scope translations (frozen at import time) to lazy getIntegrationConfigs(i18n) function - Combine split Trans fragments into single translatable units in backup-schedule retention sentence and stats-display period labels - Localize TV media type badge and full episode accessibilityLabel - Replace Android-incompatible Alert.alert language picker (max 3 buttons) with zeego DropdownMenu - Await async locale initialization before hiding splash screen so non-English users don't see English flash on cold start - Add @lingui/core as direct native app dependency Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(i18n): add Intl polyfills for Hermes, replace DropdownMenu language picker, and remove date-fns - Add `@formatjs/intl-*` polyfills loaded at app startup in `intl-polyfills.ts` so Hermes on Android has full `Intl` support for locale-aware formatting - Alias `@formatjs/icu-messageformat-parser` to its `no-parser` variant in metro config to reduce bundle size - Replace the zeego `DropdownMenu` language picker in Settings with a new `SelectModal` component that works reliably on all platforms without the Android 3-button limit - Replace `date-fns` `format`/`parseISO` in `person/[id].tsx` with `formatDate` from `@sofa/i18n/format`; remove `date-fns` from native and web `package.json` - Refactor `StatusActionButton` to use a `StatusLabel` component with `<Trans>` rather than a `getStatusConfig(t)` factory so labels are always reactive to locale changes - Fix crowdin workflow to use root `bun run i18n:compile` script instead of `cd packages/i18n && bun run compile` - Wrap root layout `GestureHandlerRootView` in `SafeAreaProvider` and add `.catch` to locale-ready promise to prevent unhandled rejections blocking the splash screen * fix(i18n): localize integration status helpers, fix stats-display Trans wrapping, and clean up SelectModal - Localize `webhookStatus` and `listStatus` in `integration-card.tsx` using `i18n._(msg`...`)` so status strings are translated instead of always rendering in English - Wrap "Movies {select}" and "Episodes {select}" in a single `<Trans>` in `stats-display.tsx` so the period selector element is embedded inside the translatable unit rather than concatenated outside it - Fix locale activation order in Settings: close the modal first, then `activateLocale`, and only call `setPersistedLocale` on success to avoid persisting a locale that failed to load - Remove the redundant `SafeAreaProvider`/`SafeAreaView` wrapper from `SelectModal` — the root layout already provides `SafeAreaProvider` - Recompile all six `.po`/`.ts` catalogs to pick up new and updated message strings * chore(i18n): crowdin sync --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { BACKUP_DIR } from "@sofa/config";
|
||||
import {
|
||||
createBackup,
|
||||
@@ -47,9 +48,15 @@ export const backupsDelete = os.admin.backups.delete
|
||||
if (err instanceof ORPCError) throw err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("not found")) {
|
||||
throw new ORPCError("NOT_FOUND", { message: msg });
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: msg,
|
||||
data: { code: AppErrorCode.BACKUP_NOT_FOUND },
|
||||
});
|
||||
}
|
||||
throw new ORPCError("BAD_REQUEST", { message: msg });
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: msg,
|
||||
data: { code: AppErrorCode.BACKUP_DELETE_FAILED },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -71,7 +78,10 @@ export const backupsRestore = os.admin.backups.restore
|
||||
if (await f.exists()) await f.delete();
|
||||
if (err instanceof ORPCError) throw err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new ORPCError("BAD_REQUEST", { message: msg });
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: msg,
|
||||
data: { code: AppErrorCode.BACKUP_RESTORE_FAILED },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -160,7 +170,10 @@ export const triggerJob = os.admin.triggerJob
|
||||
.handler(async ({ input }) => {
|
||||
const triggered = await triggerCronJob(input.name);
|
||||
if (!triggered) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Job not found" });
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "Job not found",
|
||||
data: { code: AppErrorCode.JOB_NOT_FOUND },
|
||||
});
|
||||
}
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||
import {
|
||||
getEpisodeProgressByTitleIds,
|
||||
@@ -16,6 +17,7 @@ export const discover = os.discover
|
||||
if (!isTmdbConfigured()) {
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "TMDB API key is not configured",
|
||||
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||
import {
|
||||
getEpisodeProgressByTitleIds,
|
||||
@@ -14,6 +15,7 @@ function requireTmdb() {
|
||||
if (!isTmdbConfigured()) {
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "TMDB API key is not configured",
|
||||
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import type { ParseResult } from "@sofa/core/imports";
|
||||
import {
|
||||
countUnresolved,
|
||||
@@ -31,9 +33,10 @@ export const parseFile = os.imports.parseFile
|
||||
try {
|
||||
json = await file.json();
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Invalid JSON file. Ensure it is a valid Trakt export.",
|
||||
);
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid JSON file",
|
||||
data: { code: AppErrorCode.IMPORT_INVALID_FILE },
|
||||
});
|
||||
}
|
||||
result = parseTraktPayload(
|
||||
json as Parameters<typeof parseTraktPayload>[0],
|
||||
@@ -45,9 +48,10 @@ export const parseFile = os.imports.parseFile
|
||||
try {
|
||||
json = await file.json();
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Invalid JSON file. Ensure it is a valid Simkl export.",
|
||||
);
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid JSON file",
|
||||
data: { code: AppErrorCode.IMPORT_INVALID_FILE },
|
||||
});
|
||||
}
|
||||
result = parseSimklPayload(
|
||||
json as Parameters<typeof parseSimklPayload>[0],
|
||||
@@ -99,9 +103,10 @@ export const createJob = os.imports.createJob
|
||||
data.watchlist.length +
|
||||
data.ratings.length;
|
||||
if (totalItems > 100_000) {
|
||||
throw new Error(
|
||||
`Import payload too large (${totalItems} items, max 100,000)`,
|
||||
);
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Import payload too large",
|
||||
data: { code: AppErrorCode.IMPORT_PAYLOAD_TOO_LARGE },
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent concurrent imports per user.
|
||||
@@ -137,7 +142,10 @@ export const createJob = os.imports.createJob
|
||||
.run();
|
||||
log.warn(`Auto-cancelled stale import job ${existing.id}`);
|
||||
} else {
|
||||
throw new Error("An import is already in progress");
|
||||
throw new ORPCError("CONFLICT", {
|
||||
message: "An import is already in progress",
|
||||
data: { code: AppErrorCode.IMPORT_ALREADY_RUNNING },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +183,10 @@ export const cancelJob = os.imports.cancelJob
|
||||
.handler(({ input, context }) => {
|
||||
const job = readImportJob(input.id, context.user.id);
|
||||
if (job.status !== "pending" && job.status !== "running") {
|
||||
throw new Error("Can only cancel pending or running jobs");
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Can only cancel pending or running jobs",
|
||||
data: { code: AppErrorCode.IMPORT_CANNOT_CANCEL },
|
||||
});
|
||||
}
|
||||
db.update(importJobs)
|
||||
.set({ status: "cancelled" })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { and, desc, eq } from "@sofa/db/helpers";
|
||||
import { integrationEvents, integrations } from "@sofa/db/schema";
|
||||
@@ -145,7 +146,10 @@ export const regenerateToken = os.integrations.regenerateToken
|
||||
.get();
|
||||
|
||||
if (!row) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Integration not found" });
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "Integration not found",
|
||||
data: { code: AppErrorCode.INTEGRATION_NOT_FOUND },
|
||||
});
|
||||
}
|
||||
|
||||
return serializeIntegration(row);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { fetchFullFilmography, getOrFetchPerson } from "@sofa/core/person";
|
||||
import { getUserStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
import { os } from "../context";
|
||||
@@ -9,7 +10,10 @@ export const detail = os.people.detail
|
||||
.handler(async ({ input, context }) => {
|
||||
const person = await getOrFetchPerson(input.id);
|
||||
if (!person)
|
||||
throw new ORPCError("NOT_FOUND", { message: "Person not found" });
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "Person not found",
|
||||
data: { code: AppErrorCode.PERSON_NOT_FOUND },
|
||||
});
|
||||
|
||||
const allCredits = await fetchFullFilmography(person.id);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||
import { ensureBrowsePersonsExist } from "@sofa/core/person";
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ export const search = os.search.use(authed).handler(async ({ input }) => {
|
||||
if (!isTmdbConfigured()) {
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "TMDB API key is not configured",
|
||||
data: { code: AppErrorCode.TMDB_NOT_CONFIGURED },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { getRecommendationsForTitle } from "@sofa/core/discovery";
|
||||
import { getOrFetchTitle, getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
|
||||
import {
|
||||
@@ -21,7 +22,10 @@ export const detail = os.titles.detail
|
||||
.handler(async ({ input }) => {
|
||||
const result = await getOrFetchTitle(input.id);
|
||||
if (!result)
|
||||
throw new ORPCError("NOT_FOUND", { message: "Title not found" });
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "Title not found",
|
||||
data: { code: AppErrorCode.TITLE_NOT_FOUND },
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -80,7 +84,10 @@ export const quickAdd = os.titles.quickAdd
|
||||
.where(eq(titles.id, input.id))
|
||||
.get();
|
||||
if (!title) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Title not found" });
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "Title not found",
|
||||
data: { code: AppErrorCode.TITLE_NOT_FOUND },
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger full TMDB import if still a shell
|
||||
|
||||
Reference in New Issue
Block a user