diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 50013ba2b..4e1718dc3 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -97,6 +97,7 @@ jobs: - "check" - "format" - "typecheck" + - "i18n:check" steps: - name: "πŸ“₯ Checkout PR code" diff --git a/components/login/TVPasswordEntryModal.tsx b/components/login/TVPasswordEntryModal.tsx index 596bb610a..7db1671e9 100644 --- a/components/login/TVPasswordEntryModal.tsx +++ b/components/login/TVPasswordEntryModal.tsx @@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC = ({ diff --git a/package.json b/package.json index ccabd2f47..3158612e2 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "lint": "biome check --write --unsafe --max-diagnostics 1000", "format": "biome format --write .", "doctor": "expo-doctor", - "test": "bun run typecheck && bun run lint && bun run format && bun run doctor", + "i18n:check": "bun scripts/check-i18n-keys.mjs", + "i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused", + "test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor", "postinstall": "patch-package" }, "dependencies": { diff --git a/scripts/check-i18n-keys.mjs b/scripts/check-i18n-keys.mjs new file mode 100644 index 000000000..0e20e462e --- /dev/null +++ b/scripts/check-i18n-keys.mjs @@ -0,0 +1,273 @@ +#!/usr/bin/env bun +/** + * i18n key checker for Streamyfin. + * + * Detects: + * - MISSING keys: a static `t("a.b.c")` / `i18nKey="a.b.c"` referenced in the code + * that does not exist in the source locale (translations/en.json). These are bugs β€” + * the app renders the raw key. Always fails CI. + * - UNUSED (dead) keys: a key in the source locale that is referenced nowhere in the + * code, neither statically nor via a detected dynamic prefix (`t(`a.b.${x}`)`). + * These are dead weight that also clutter every locale on Crowdin. + * + * Dynamic usage is handled conservatively: + * - `t(`prefix.${x}`)` -> every key starting with `prefix.` is considered used. + * - `t(`${x}`)` -> fully dynamic, reported for manual review, never used to + * whitelist keys (in Streamyfin these are user-defined section + * titles, not translation keys). + * - Edge cases the static scan cannot see can be allow-listed in the config file. + * + * Usage: + * bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused + * bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning + * bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely + * bun scripts/check-i18n-keys.mjs --json # machine-readable output + * bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest) + */ + +import { + existsSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { extname, join, relative } from "node:path"; + +const ROOT = process.cwd(); +const args = process.argv.slice(2); +const flag = (name, def) => { + const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`)); + if (!a) return def; + const [, v] = a.split("="); + return v === undefined ? true : v; +}; +const UNUSED_MODE = String(flag("unused", "error")); // error | warn | off +const JSON_OUT = !!flag("json", false); +const FIX_UNUSED = !!flag("fix-unused", false); + +// ---- config ---- +const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json"); +const DEFAULT_CONFIG = { + localesDir: "translations", + sourceLocale: "en", + // Scan the whole repo by default so keys referenced outside the obvious dirs + // (e.g. packages/, key constants in utils/atoms) are not wrongly flagged as dead. + srcDirs: ["."], + srcExtensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], + excludeDirs: [ + "node_modules", + "ios", + "android", + ".expo", + ".git", + "dist", + "build", + "translations", + "scripts", + ], + // Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally. + ignoreUnused: [], +}; +const config = existsSync(CONFIG_PATH) + ? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) } + : DEFAULT_CONFIG; + +// ---- helpers ---- +const flatten = (obj, prefix = "", out = {}) => { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out); + else out[key] = v; + } + return out; +}; + +const globMatch = (key, pattern) => { + if (pattern.endsWith(".*")) + return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1)); + if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1)); + return key === pattern; +}; + +const walk = (dir, files = []) => { + let entries; + try { + entries = readdirSync(dir); + } catch { + return files; + } + for (const name of entries) { + const full = join(dir, name); + let st; + try { + st = statSync(full); + } catch { + continue; + } + if (st.isDirectory()) { + if (config.excludeDirs.includes(name)) continue; + walk(full, files); + } else if (config.srcExtensions.includes(extname(name))) { + files.push(full); + } + } + return files; +}; + +// ---- load source keys ---- +const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`); +const sourceKeys = Object.keys( + flatten(JSON.parse(readFileSync(sourcePath, "utf8"))), +); +const sourceKeySet = new Set(sourceKeys); + +// ---- scan code ---- +const STATIC_RE = /\bt\(\s*(['"])((?:\\.|(?!\1).)+?)\1/g; // t("a.b") / t('a.b') +const TPL_STATIC_RE = /\bt\(\s*`([^`$]+)`/g; // t(`a.b`) no interpolation +const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b." +const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // +const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y + +const usedStatic = new Set(); // keys passed to t(...) / i18nKey β€” used for MISSING detection +const dynamicPrefixes = new Set(); +const fullyDynamic = []; // { file, line } +let codeBlob = ""; // all (comment-stripped) source text β€” searched for delimited key literals + +// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as +// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers); +// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps +// `://` inside string URLs intact. +const stripComments = (src) => + src + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) + .replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p); + +const files = config.srcDirs.flatMap((d) => + walk(join(ROOT, d === "." ? "" : d) || ROOT), +); +for (const file of files) { + const text = readFileSync(file, "utf8"); + const clean = stripComments(text); + codeBlob += `\n${clean}`; + for (const m of clean.matchAll(STATIC_RE)) usedStatic.add(m[2]); + for (const m of clean.matchAll(TPL_STATIC_RE)) usedStatic.add(m[1]); + for (const m of clean.matchAll(I18NKEY_RE)) usedStatic.add(m[2]); + for (const m of clean.matchAll(TPL_DYN_RE)) { + const prefix = m[1]; + if (prefix?.includes(".")) dynamicPrefixes.add(prefix); + else { + const idx = clean.slice(0, m.index).split("\n").length; + fullyDynamic.push({ file: relative(ROOT, file), line: idx }); + } + } +} + +const prefixList = [...dynamicPrefixes]; +// A key counts as used if its EXACT delimited literal ("k" / 'k' / `k`) appears anywhere in +// the code (covers t("k"), , and keys stored as bare string constants in +// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or +// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c). +const literalUsed = (key) => + codeBlob.includes(`"${key}"`) || + codeBlob.includes(`'${key}'`) || + codeBlob.includes(`\`${key}\``); +const isUsed = (key) => + literalUsed(key) || + prefixList.some((p) => key.startsWith(p)) || + config.ignoreUnused.some((g) => globMatch(key, g)); + +// ---- compute ---- +const unused = sourceKeys.filter((k) => !isUsed(k)).sort(); +// Static references are always validated, even under a dynamic prefix: a dynamic prefix only +// affects the UNUSED calculation, never MISSING. +const missing = [...usedStatic] + .filter((k) => KEY_SHAPE.test(k) && !sourceKeySet.has(k)) + .sort(); +// Known limitation: only keys seen in a static t("…") / i18nKey="…" / t(`…`) call are +// validated for MISSING. A key stored as a bare string constant and resolved via t(variable) +// counts as USED (via literalUsed β†’ not flagged unused) but its existence in en.json is not +// checked here β€” static analysis can't resolve which key a runtime variable holds. Streamyfin +// keys are static literals in practice; revisit if dynamic key constants become common. + +// ---- optional fix: strip dead keys from the source locale (en.json) ---- +const removeKey = (obj, parts) => { + const [head, ...rest] = parts; + if (!(head in obj)) return; + if (rest.length === 0) { + delete obj[head]; + return; + } + removeKey(obj[head], rest); + if ( + obj[head] && + typeof obj[head] === "object" && + Object.keys(obj[head]).length === 0 + ) + delete obj[head]; +}; +if (FIX_UNUSED && unused.length) { + // Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes + // the keys from them automatically on the next sync once they disappear from the source. + const data = JSON.parse(readFileSync(sourcePath, "utf8")); + for (const key of unused) removeKey(data, key.split(".")); + writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`); + console.log( + `🧹 Removed ${unused.length} dead key(s) from ${config.sourceLocale}.json (Crowdin will sync the other locales).`, + ); +} + +// ---- report ---- +if (JSON_OUT) { + console.log( + JSON.stringify( + { + sourceKeys: sourceKeys.length, + missing, + unused, + dynamicPrefixes: prefixList, + fullyDynamic, + }, + null, + 2, + ), + ); +} else { + console.log( + `πŸ”‘ i18n key check β€” source: ${relative(ROOT, sourcePath)} (${sourceKeys.length} keys), scanned ${files.length} files`, + ); + if (prefixList.length) + console.log( + ` dynamic prefixes treated as used: ${prefixList.map((p) => `${p}*`).join(", ")}`, + ); + if (fullyDynamic.length) + console.log( + ` ⚠️ ${fullyDynamic.length} fully-dynamic t(\`\${…}\`) call(s) (not key-based, manual review): ${fullyDynamic.map((d) => `${d.file}:${d.line}`).join(", ")}`, + ); + + if (missing.length) { + console.log( + `\n❌ MISSING keys (used in code, absent from ${config.sourceLocale}.json) β€” ${missing.length}:`, + ); + for (const k of missing) console.log(` - ${k}`); + } else console.log("\nβœ… No missing keys."); + + if (UNUSED_MODE !== "off") { + if (unused.length) { + console.log( + `\n${UNUSED_MODE === "error" ? "❌" : "⚠️ "} UNUSED keys (in ${config.sourceLocale}.json, referenced nowhere) β€” ${unused.length}:`, + ); + for (const k of unused) console.log(` - ${k}`); + console.log( + `\n β†’ remove with: bun scripts/check-i18n-keys.mjs --fix-unused`, + ); + console.log( + ` β†’ or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`, + ); + } else console.log("\nβœ… No unused keys."); + } +} + +const fail = + missing.length > 0 || (UNUSED_MODE === "error" && unused.length > 0); +process.exit(fail ? 1 : 0); diff --git a/scripts/i18n-keys.config.json b/scripts/i18n-keys.config.json new file mode 100644 index 000000000..c5196d825 --- /dev/null +++ b/scripts/i18n-keys.config.json @@ -0,0 +1,46 @@ +{ + "localesDir": "translations", + "sourceLocale": "en", + "srcDirs": [ + "app", + "components", + "hooks", + "providers", + "utils", + "modules", + "packages", + "constants" + ], + "srcExtensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], + "excludeDirs": [ + "node_modules", + "ios", + "android", + ".expo", + ".git", + "dist", + "build", + "translations" + ], + "_ignoreUnusedNote": "Keys present in en.json but referenced by no t() call β€” allow-listed so the unused-key cleanup keeps them. Two kinds: (1) ORPHANS of features that already exist but render hardcoded strings or use other keys β€” watchlists.* (add/remove, handled by WatchlistSheet via hooks), pin.* (confirm step of the existing PIN setup flow), player.* (in-player subtitle search, swipe-down hint, stop-playback confirm); these should be wired to the live UI or removed in a follow-up i18n pass, not assumed to be future work. (2) GENUINELY PLANNED, tracked by issues: home.settings.other.show_large_home_carousel (#1702 media-bar), home.settings.logs.delete_all_logs (#1703 iOS logs fix), home.suggested_episodes (#1704).", + "ignoreUnused": [ + "watchlists.add_to_watchlist", + "watchlists.remove_from_watchlist", + "watchlists.create_one_first", + "watchlists.no_compatible_watchlists", + "pin.confirm_pin", + "pin.pins_dont_match", + "player.search_subtitles", + "player.subtitle_search", + "player.subtitle_download_hint", + "player.subtitle_tracks", + "player.using_jellyfin_server", + "player.swipe_down_settings", + "player.stopPlayback", + "player.stopPlayingTitle", + "player.stopPlayingConfirm", + "home.settings.other.show_large_home_carousel", + "home.settings.logs.delete_all_logs", + "home.suggested_episodes" + ] +} diff --git a/translations/en.json b/translations/en.json index 581e82c48..25b978380 100644 --- a/translations/en.json +++ b/translations/en.json @@ -261,43 +261,6 @@ "None": "None", "OnlyForced": "Only forced" }, - "text_color": "Text color", - "background_color": "Background color", - "outline_color": "Outline color", - "outline_thickness": "Outline thickness", - "background_opacity": "Background opacity", - "outline_opacity": "Outline opacity", - "bold_text": "Bold text", - "colors": { - "Black": "Black", - "Gray": "Gray", - "Silver": "Silver", - "White": "White", - "Maroon": "Maroon", - "Red": "Red", - "Fuchsia": "Fuchsia", - "Yellow": "Yellow", - "Olive": "Olive", - "Green": "Green", - "Teal": "Teal", - "Lime": "Lime", - "Purple": "Purple", - "Navy": "Navy", - "Blue": "Blue", - "Aqua": "Aqua" - }, - "thickness": { - "None": "None", - "Thin": "Thin", - "Normal": "Normal", - "Thick": "Thick" - }, - "subtitle_color": "Subtitle color", - "subtitle_background_color": "Background color", - "subtitle_font": "Subtitle font", - "ksplayer_title": "KSPlayer settings", - "hardware_decode": "Hardware decoding", - "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.", "opensubtitles_title": "OpenSubtitles", "opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.", "opensubtitles_api_key": "API key", @@ -315,25 +278,6 @@ "bottom": "Bottom" } }, - "vlc_subtitles": { - "title": "VLC subtitle settings", - "hint": "Customize subtitle appearance for the VLC player. Changes take effect on next playback.", - "text_color": "Text color", - "background_color": "Background color", - "background_opacity": "Background opacity", - "outline_color": "Outline color", - "outline_opacity": "Outline opacity", - "outline_thickness": "Outline thickness", - "bold": "Bold text", - "margin": "Bottom margin" - }, - "video_player": { - "title": "Video player", - "video_player": "Video player", - "video_player_description": "Choose which video player to use on iOS.", - "ksplayer": "KSPlayer", - "vlc": "VLC" - }, "other": { "other_title": "Other", "video_orientation": "Video orientation", @@ -351,11 +295,6 @@ "UNKNOWN": "Unknown" }, "safe_area_in_controls": "Safe area in controls", - "video_player": "Video player", - "video_players": { - "VLC_3": "VLC 3", - "VLC_4": "VLC 4 (Experimental + PiP)" - }, "show_custom_menu_links": "Show custom menu links", "show_large_home_carousel": "Show large home carousel (beta)", "hide_libraries": "Hide libraries", @@ -367,9 +306,6 @@ "max_auto_play_episode_count": "Max auto-play episode count", "disabled": "Disabled" }, - "downloads": { - "downloads_title": "Downloads" - }, "music": { "title": "Music", "playback_title": "Playback", @@ -413,23 +349,18 @@ "read_more_about_marlin": "Read more about Marlin.", "save_button": "Save", "toasts": { - "saved": "Saved", - "refreshed": "Settings refreshed from server" - }, - "refresh_from_server": "Refresh settings from server" + "saved": "Saved" + } }, "streamystats": { - "enable_streamystats": "Enable Streamystats", "disable_streamystats": "Disable Streamystats", "enable_search": "Use for search", "url": "URL", "server_url_placeholder": "http(s)://streamystats.example.com", "streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.", "read_more_about_streamystats": "Read more about Streamystats.", - "save_button": "Save", "save": "Save", "features_title": "Features", - "home_sections_title": "Home sections", "enable_movie_recommendations": "Movie recommendations", "enable_series_recommendations": "Series recommendations", "enable_promoted_watchlists": "Promoted watchlists", @@ -445,8 +376,7 @@ "refresh_from_server": "Refresh settings from server" }, "kefinTweaks": { - "watchlist_enabler": "Enable watchlist integration", - "watchlist_button": "Toggle watchlist integration" + "watchlist_enabler": "Enable watchlist integration" } }, "storage": { @@ -457,7 +387,6 @@ "delete_all_downloaded_files": "Delete all downloaded files", "music_cache_title": "Music cache", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", - "enable_music_cache": "Enable music cache", "clear_music_cache": "Clear music cache", "music_cache_size": "{{size}} cached", "music_cache_cleared": "Music cache cleared", @@ -467,8 +396,6 @@ "clear_all_cache": "Clear all cache", "clear_all_cache_confirm": "Clear all cache?", "clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.", - "clear_all_cache_success": "Cache cleared", - "clear_all_cache_success_desc": "All cache has been cleared successfully.", "clear_all_cache_error_desc": "An error occurred while clearing the cache." }, "intro": { @@ -490,15 +417,12 @@ "system": "System" }, "toasts": { - "error_deleting_files": "Error deleting files", - "background_downloads_enabled": "Background downloads enabled", - "background_downloads_disabled": "Background downloads disabled" + "error_deleting_files": "Error deleting files" }, "security": { "title": "Security", "inactivity_timeout": { "title": "Inactivity timeout", - "description": "Auto logout after inactivity", "disabled": "Disabled", "1_minute": "1 minute", "5_minutes": "5 minutes", @@ -508,6 +432,10 @@ "4_hours": "4 hours", "24_hours": "24 hours" } + }, + "dashboard": { + "title": "Dashboard", + "sessions_title": "Sessions" } }, "sessions": { @@ -518,10 +446,7 @@ "downloads_title": "Downloads", "series": "Series", "movies": "Movies", - "queue": "Queue", "other_media": "Other media", - "queue_hint": "Queue and downloads will be lost on app restart", - "no_items_in_queue": "No items in queue", "no_downloaded_items": "No downloaded items", "delete_all_movies_button": "Delete all movies", "delete_all_series_button": "Delete all series", @@ -546,13 +471,8 @@ "failed_to_delete_all_series": "Failed to delete all series", "deleted_media_successfully": "Deleted other media successfully!", "failed_to_delete_media": "Failed to delete other media", - "download_deleted": "Download deleted", "download_cancelled": "Download cancelled", "could_not_delete_download": "Could not delete download", - "download_paused": "Download paused", - "could_not_pause_download": "Could not pause download", - "download_resumed": "Download resumed", - "could_not_resume_download": "Could not resume download", "download_completed": "Download completed", "download_failed": "Download failed", "download_failed_for_item": "Download failed for {{item}} - {{error}}", @@ -562,10 +482,7 @@ "item_already_downloading": "{{item}} is already downloading", "all_files_deleted": "All downloads deleted successfully", "files_deleted_by_type": "{{count}} {{type}} deleted", - "all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully", - "failed_to_clean_cache_directory": "Failed to clean cache directory", "could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}", - "go_to_downloads": "Go to Downloads", "file_deleted": "{{item}} deleted" } } @@ -583,16 +500,17 @@ "none": "None", "track": "Track", "cancel": "Cancel", - "stop": "Stop", "delete": "Delete", "ok": "OK", "remove": "Remove", - "next": "Next", "back": "Back", "continue": "Continue", "verifying": "Verifying...", "login": "Login", - "refresh": "Refresh" + "episodes": "Episodes", + "movies": "Movies", + "loading": "Loading…", + "seeAll": "See all" }, "search": { "search": "Search...", @@ -691,10 +609,6 @@ "could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast", "message_from_server": "Message from server: {{message}}", "next_episode": "Next episode", - "refresh_tracks": "Refresh tracks", - "audio_tracks": "Audio tracks:", - "playback_state": "Playback state:", - "index": "Index:", "continue_watching": "Continue watching", "go_back": "Go back", "downloaded_file_title": "You have this file downloaded", @@ -723,7 +637,8 @@ "stopPlayback": "Stop playback", "stopPlayingTitle": "Stop playing \"{{title}}\"?", "stopPlayingConfirm": "Are you sure you want to stop playback?", - "downloaded": "Downloaded" + "downloaded": "Downloaded", + "missing_parameters": "Missing playback parameters" }, "chapters": { "title": "Chapters", @@ -761,7 +676,6 @@ "show_more": "Show more", "show_less": "Show less", "left": "left", - "more_info": "More info", "director": "Director", "cast": "Cast", "technical_details": "Technical details", @@ -784,7 +698,8 @@ "resume_playback": "Resume playback", "resume_playback_description": "Do you want to continue where you left off or start from the beginning?", "play_from_start": "Play from start", - "continue_from": "Continue from {{time}}" + "continue_from": "Continue from {{time}}", + "no_data_available": "No data available" }, "live_tv": { "next": "Next", @@ -888,13 +803,9 @@ "playlists": "Playlists", "tracks": "Songs" }, - "filters": { - "all": "All" - }, "recently_added": "Recently added", "recently_played": "Recently played", "frequently_played": "Frequently played", - "explore": "Explore", "top_tracks": "Top songs", "play": "Play", "shuffle": "Shuffle", @@ -1028,7 +939,6 @@ "pairing": { "pair_with_phone": "Pair with phone", "pair_with_phone_title": "Log in on TV", - "pair_with_phone_description": "Scan the QR code displayed on your TV to log in", "waiting_for_phone": "Waiting for phone...", "scan_with_phone": "Scan with the Streamyfin app on your phone", "logging_in": "Logging in...",