mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-11 08:20:25 +01:00
Merge remote-tracking branch 'origin/develop' into ci/auto-update-issue-form
# Conflicts: # .github/ISSUE_TEMPLATE/issue_report.yml
This commit is contained in:
273
scripts/check-i18n-keys.mjs
Normal file
273
scripts/check-i18n-keys.mjs
Normal file
@@ -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; // <Trans i18nKey="a.b">
|
||||
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"), <Trans i18nKey>, 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);
|
||||
236
scripts/detect-duplicate-issue.mjs
Normal file
236
scripts/detect-duplicate-issue.mjs
Normal file
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Flags likely-duplicate issues when a new issue is opened, using lexical similarity
|
||||
* (Jaccard over word sets of the title and body) — no API key, no embeddings.
|
||||
*
|
||||
* On a match it posts ONE comment listing the closest open issues and adds the
|
||||
* "possible duplicate" label. If nothing is similar enough, it does nothing.
|
||||
*
|
||||
* Env:
|
||||
* GITHUB_REPOSITORY owner/repo
|
||||
* ISSUE_NUMBER the new issue number
|
||||
* ISSUE_TITLE the new issue title
|
||||
* ISSUE_BODY the new issue body
|
||||
* GH_TOKEN/GITHUB_TOKEN for gh (provided in CI)
|
||||
* DUP_THRESHOLD similarity threshold 0..1 (default 0.3)
|
||||
* DUP_MAX max matches to report (default 5)
|
||||
* DUP_FIXTURE optional path to a JSON array of {number,title,body} (local testing)
|
||||
* DRY_RUN if set, print results instead of commenting/labelling
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
|
||||
const numEnv = (name, def) => {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === "") return def;
|
||||
const n = Number(raw);
|
||||
return Number.isNaN(n) ? def : n;
|
||||
};
|
||||
|
||||
const REPO = process.env.GITHUB_REPOSITORY || "streamyfin/streamyfin";
|
||||
const NUMBER = numEnv("ISSUE_NUMBER", Number.NaN);
|
||||
const TITLE = process.env.ISSUE_TITLE || "";
|
||||
const BODY = process.env.ISSUE_BODY || "";
|
||||
const THRESHOLD = numEnv("DUP_THRESHOLD", 0.3);
|
||||
const MAX = numEnv("DUP_MAX", 5);
|
||||
const DRY = !!process.env.DRY_RUN;
|
||||
const LABEL = "possible duplicate";
|
||||
const MARKER = "<!-- duplicate-detector -->";
|
||||
|
||||
// Generic stop words only — keep domain/feature/platform words (android, downloads,
|
||||
// subtitles…) since those are exactly what makes two reports the same or different.
|
||||
const STOP = new Set(
|
||||
(
|
||||
"a an the and or but if then of to in on at by for with from as is are was were be been being do does did " +
|
||||
"it its this that these those i you we they me my your our their he she him her " +
|
||||
"when while where what which who how why so just then than too very can could would should will " +
|
||||
"not no nor only own same s t don dont im ive please thanks hi hello also still get got use used using " +
|
||||
"app application streamyfin issue bug"
|
||||
).split(/\s+/),
|
||||
);
|
||||
|
||||
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
|
||||
|
||||
const tokens = (s) =>
|
||||
(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
|
||||
.replace(/<!--[\s\S]*?-->/g, " ") // drop html comments
|
||||
.replace(/https?:\/\/\S+/g, " ") // drop urls
|
||||
.replace(/[^a-z0-9\s]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 2 && !STOP.has(w))
|
||||
.map(stem)
|
||||
.filter((w) => w.length > 2);
|
||||
|
||||
const jaccard = (a, b) => {
|
||||
const A = new Set(a);
|
||||
const B = new Set(b);
|
||||
if (!A.size || !B.size) return 0;
|
||||
let inter = 0;
|
||||
for (const x of A) if (B.has(x)) inter++;
|
||||
return inter / (A.size + B.size - inter);
|
||||
};
|
||||
|
||||
const newTitle = tokens(TITLE);
|
||||
const newBody = tokens(BODY);
|
||||
const score = (o) =>
|
||||
0.6 * jaccard(newTitle, tokens(o.title)) +
|
||||
0.4 * jaccard(newBody, tokens(o.body));
|
||||
|
||||
// fetch open issues (excluding PRs and the new issue itself)
|
||||
let issues;
|
||||
if (process.env.DUP_FIXTURE) {
|
||||
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
|
||||
} else {
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
`repos/${REPO}/issues`,
|
||||
"--paginate",
|
||||
"-X",
|
||||
"GET",
|
||||
"-f",
|
||||
"state=open",
|
||||
"-f",
|
||||
"per_page=100",
|
||||
"--jq",
|
||||
".[] | select(.pull_request | not) | {number, title, body}",
|
||||
],
|
||||
{ encoding: "utf8", maxBuffer: 1e8 },
|
||||
);
|
||||
issues = raw
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
const matches = issues
|
||||
.filter((o) => o.number !== NUMBER)
|
||||
.map((o) => ({ ...o, s: score(o) }))
|
||||
.filter((o) => o.s >= THRESHOLD)
|
||||
.sort((a, b) => b.s - a.s)
|
||||
.slice(0, MAX);
|
||||
|
||||
if (!matches.length) {
|
||||
console.log("No likely duplicates found.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Neutralise other issues' titles before echoing them back: break @mentions and
|
||||
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
|
||||
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
|
||||
const safeTitle = (t) =>
|
||||
(t || "")
|
||||
.replace(/@/g, "@")
|
||||
.replace(/[`<>|*_~[\]]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 140);
|
||||
const list = matches
|
||||
.map(
|
||||
(m) =>
|
||||
`- #${m.number} — ${safeTitle(m.title)} (≈ ${Math.round(m.s * 100)}% similar)`,
|
||||
)
|
||||
.join("\n");
|
||||
const comment = [
|
||||
MARKER,
|
||||
"🔍 **This looks like it might be a duplicate.** Possibly related open issues:",
|
||||
"",
|
||||
list,
|
||||
"",
|
||||
"If yours is different, ignore this — a maintainer will confirm. Otherwise, please 👍 the existing issue and add any extra details there.",
|
||||
].join("\n");
|
||||
|
||||
console.log(`Found ${matches.length} possible duplicate(s):\n${list}`);
|
||||
|
||||
if (DRY) {
|
||||
console.log("\nDRY_RUN: not commenting/labelling.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Live mode needs a real issue number; refuse rather than POST to /issues/NaN/...
|
||||
if (!Number.isInteger(NUMBER) || NUMBER <= 0) {
|
||||
console.error(
|
||||
`Invalid ISSUE_NUMBER ${JSON.stringify(process.env.ISSUE_NUMBER)} — refusing to comment.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Idempotency: skip if we've already flagged this issue (guards re-runs / future triggers).
|
||||
const priorComments = execFileSync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
`repos/${REPO}/issues/${NUMBER}/comments`,
|
||||
"--paginate",
|
||||
"--jq",
|
||||
".[].body",
|
||||
],
|
||||
{ encoding: "utf8", maxBuffer: 1e8 },
|
||||
);
|
||||
if (priorComments.includes(MARKER)) {
|
||||
console.log("Already flagged (marker present); skipping.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
execFileSync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
"-X",
|
||||
"POST",
|
||||
`repos/${REPO}/issues/${NUMBER}/comments`,
|
||||
"-f",
|
||||
`body=${comment}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
try {
|
||||
execFileSync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
"-X",
|
||||
"POST",
|
||||
`repos/${REPO}/issues/${NUMBER}/labels`,
|
||||
"-f",
|
||||
`labels[]=${LABEL}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
} catch {
|
||||
// label may not exist yet — create then add
|
||||
execFileSync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
"-X",
|
||||
"POST",
|
||||
`repos/${REPO}/labels`,
|
||||
"-f",
|
||||
`name=${LABEL}`,
|
||||
"-f",
|
||||
"color=fbca04",
|
||||
"-f",
|
||||
"description=Automatically flagged as a possible duplicate",
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
execFileSync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
"-X",
|
||||
"POST",
|
||||
`repos/${REPO}/issues/${NUMBER}/labels`,
|
||||
"-f",
|
||||
`labels[]=${LABEL}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
}
|
||||
console.log("Commented and labelled.");
|
||||
46
scripts/i18n-keys.config.json
Normal file
46
scripts/i18n-keys.config.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user