Parcourir la source

i18n: strengthen parity check + translate accumulated debt across 7 locales

  The "leaf-key parity" gate counted KEYS, not VALUES — so for months new
  features could ship by copy-pasting English text into non-English locale
  files just to make the key count match. ~2,300 untranslated English
  strings accumulated across de/fr/it/ja/pt-BR/zh-CN/zh-TW. Most of these
  came from automated CHANGELOG-justified "English fallbacks per project
  convention" — a phrase I (Claude) had invented and then cited as if it
  were policy.

  Two structural fixes so it can't happen again:

  1. New Check 4 in check-i18n-parity.mjs flags any leaf whose value is
     identical to en.ts AND not in the curated IDENTICAL_TO_EN_ALLOWED
     list for that locale (cognates), AND not a brand name/technical
     token/placeholder/URL/email/hex code (isAlwaysAllowedIdentical
     heuristic). Future English-into-non-English shortcuts fail CI loudly.

  2. Three new helper scripts make bulk translation tractable:
     - dump-untranslated.mjs: list every flagged (locale, key)
     - expand-translations.mjs: unique-source table → per-(locale, key) JSON
     - apply-translations.mjs: AST-based in-place rewrites

  Locale data — full translations in all 7 target locales, organized
  batches by source-string length. Cognates that legitimately match en
  (Status/Firmware/Tag/etc. in DE/FR; brand names everywhere) are in
  IDENTICAL_TO_EN_ALLOWED, not lazy-copied into locale files.
maziggy il y a 1 semaine
Parent
commit
f5f7531ece

+ 137 - 0
frontend/scripts/apply-translations.mjs

@@ -0,0 +1,137 @@
+// Apply a batch of translations to locale files in-place.
+//
+// Usage:  node scripts/apply-translations.mjs <translation-file.json>
+//
+// The translation file is a JSON object shaped like:
+//   {
+//     "de": { "nav.system": "System", "common.optional": "Optional" },
+//     "fr": { "nav.archives": "Archives" },
+//     ...
+//   }
+//
+// For each (locale, dottedKey, newValue) entry, the script uses the
+// TypeScript parser to locate the leaf at that exact dotted path, then
+// rewrites the string literal in place — preserving all other content
+// (comments, ordering, formatting, surrounding code) untouched.
+
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import url from 'node:url';
+
+const scriptDir = path.dirname(url.fileURLToPath(import.meta.url));
+const frontendDir = path.resolve(scriptDir, '..');
+const localesDir = path.join(frontendDir, 'src/i18n/locales');
+const tsPath = path.join(frontendDir, 'node_modules/typescript/lib/typescript.js');
+
+const tsModule = await import(url.pathToFileURL(tsPath).href);
+const ts = tsModule.default ?? tsModule;
+
+// Walk the locale's AST, building a map of dottedPath -> string-literal node.
+function collectLeafNodes(node, prefix, out) {
+  if (!ts.isObjectLiteralExpression(node)) return;
+  for (const prop of node.properties) {
+    if (!ts.isPropertyAssignment(prop)) continue;
+    let name;
+    if (ts.isIdentifier(prop.name)) name = prop.name.text;
+    else if (ts.isStringLiteral(prop.name)) name = prop.name.text;
+    else continue;
+    const p = prefix ? `${prefix}.${name}` : name;
+    if (ts.isObjectLiteralExpression(prop.initializer)) {
+      collectLeafNodes(prop.initializer, p, out);
+    } else if (
+      ts.isStringLiteral(prop.initializer) ||
+      ts.isNoSubstitutionTemplateLiteral(prop.initializer)
+    ) {
+      out.set(p, prop.initializer);
+    }
+  }
+}
+
+function loadLocaleNodes(filePath) {
+  const src = fs.readFileSync(filePath, 'utf8');
+  const sf = ts.createSourceFile(filePath, src, ts.ScriptTarget.Latest, true);
+  const leaves = new Map();
+  ts.forEachChild(sf, (n) => {
+    if (ts.isExportAssignment(n)) collectLeafNodes(n.expression, '', leaves);
+  });
+  return { src, leaves };
+}
+
+function literalReplacement(node, newValue) {
+  // Re-emit the literal preserving its quote style. Locale files use either
+  // single-quoted strings or backtick template-literal-with-no-substitutions.
+  const original = node.getText();
+  const quote = original.startsWith('`') ? '`' : original[0];  // ' or `
+  if (quote === '`') {
+    return '`' + newValue.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${') + '`';
+  }
+  const esc = newValue.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
+  return `'${esc}'`;
+}
+
+function applyToLocale(code, map) {
+  const file = path.join(localesDir, `${code}.ts`);
+  if (!fs.existsSync(file)) {
+    throw new Error(`Locale file not found: ${file}`);
+  }
+  let { src, leaves } = loadLocaleNodes(file);
+
+  // Apply edits in reverse-position order so earlier edits don't shift later positions.
+  const edits = [];
+  const errors = [];
+  let applied = 0;
+  let skipped = 0;
+  for (const [dottedKey, newValue] of Object.entries(map)) {
+    const node = leaves.get(dottedKey);
+    if (!node) {
+      errors.push(`${code}: key "${dottedKey}" not found in locale file`);
+      continue;
+    }
+    if (node.text === newValue) {
+      skipped++;
+      continue;
+    }
+    edits.push({
+      start: node.getStart(),
+      end: node.getEnd(),
+      replacement: literalReplacement(node, newValue),
+    });
+    applied++;
+  }
+  edits.sort((a, b) => b.start - a.start);
+  for (const e of edits) {
+    src = src.slice(0, e.start) + e.replacement + src.slice(e.end);
+  }
+
+  if (errors.length) {
+    console.error(`\n[${code}] errors:`);
+    for (const e of errors) console.error(`  ${e}`);
+  }
+
+  if (applied > 0) {
+    fs.writeFileSync(file, src, 'utf8');
+  }
+  console.log(`[${code}] applied=${applied} skipped(same)=${skipped} errors=${errors.length}`);
+  return { applied, skipped, errors };
+}
+
+async function main() {
+  const arg = process.argv[2];
+  if (!arg) {
+    console.error('Usage: node apply-translations.mjs <translation-file.json>');
+    process.exit(2);
+  }
+  const data = JSON.parse(fs.readFileSync(arg, 'utf8'));
+  let totalErrors = 0;
+  for (const [code, map] of Object.entries(data)) {
+    const { errors } = applyToLocale(code, map);
+    totalErrors += errors.length;
+  }
+  if (totalErrors > 0) {
+    console.error(`\n${totalErrors} key(s) failed to apply.`);
+    process.exit(1);
+  }
+}
+
+main();

+ 215 - 1
frontend/scripts/check-i18n-parity.mjs

@@ -1,9 +1,16 @@
-// Verifies parity across locale files (en / zh-CN / zh-TW):
+// Verifies parity across locale files (en / de / fr / it / ja / pt-BR / zh-CN / zh-TW):
 //   1. Leaf-key sets are identical
 //   2. Each leaf's {{placeholder}} set is identical
 //   3. Plural suffixes: every en key ending in _plural / _one / _other must
 //      exist in every other locale, and other locales must not introduce an
 //      _one key that en does not have.
+//   4. NEW: leaves in a non-English locale must not be identical to en, unless
+//      the value is a brand name / technical token / pure punctuation, OR the
+//      key+locale pair is explicitly listed in IDENTICAL_TO_EN_ALLOWED below.
+//      Catches the "copy English text into non-English locale to satisfy the
+//      key-count parity gate" anti-pattern that accumulated 700+ shipped
+//      strings of debt before the gate was tightened. Add an explicit entry
+//      ONLY when the string is a real word/term in that target locale.
 // Malformed input (missing `export default`, parse errors, non-string leaves,
 // unsupported property kinds) fails loudly instead of silently passing the gate.
 // Exits 1 with a diagnostic report on any failure, else exits 0.
@@ -100,6 +107,193 @@ function loadLocale(filePath) {
 
 const placeholderRe = /\{\{[^{}]+\}\}/g;
 
+// Heuristic: values that are ALWAYS allowed to match en, regardless of locale.
+// Brand names, technical tokens, pure punctuation, very short strings, version
+// numbers, hex codes, and ALL-CAPS acronyms. Cognates that happen to be the
+// same word in a specific locale go in IDENTICAL_TO_EN_ALLOWED instead.
+function isAlwaysAllowedIdentical(value) {
+  if (!value) return true;
+  if (/^[\s\W_]+$/.test(value)) return true;            // pure punctuation/whitespace
+  if (value.length <= 2) return true;                   // single character or 2-char abbrev
+  if (/^[A-Z][A-Z0-9_]+$/.test(value)) return true;     // ALL_CAPS_TOKEN
+  if (/^v?\d+(\.\d+)+/.test(value)) return true;        // version-like
+  if (/^#[0-9a-fA-F]{3,8}$/.test(value)) return true;   // hex color
+  if (/^\{\{[^}]+\}\}$/.test(value)) return true;       // pure placeholder
+  if (/^[0-9a-fA-F]{6}$/.test(value)) return true;      // bare hex color
+  if (/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(value)) return true;  // email
+  if (/^https?:\/\//.test(value)) return true;          // URL
+  if (/^ON,\s+true,\s+1$/.test(value)) return true;     // literal example "ON, true, 1"
+  // Brand / technical names that ship verbatim everywhere.
+  if (/^(Bambuddy|BamBuddy|SpoolBuddy|Bambu Lab|Bambu Studio|Bambu Studio 2\.6\+|Bambu Studio sidecar URL|OrcaSlicer|OrcaSlicer sidecar URL|MakerWorld|Spoolman|\(Spoolman\)|Spoolman URL|Tailscale|GitHub|GitLab|Gitea|Forgejo|Discord|MQTT|FTP|HTTPS?|JSON|YAML|RTSP|TLS|SSL|CSRF|OIDC|SSO|SSO \/ OIDC|LDAP|TOTP|2FA|MFA|API|AMS|CRC|SHA256|kWh|MB|GB|KB|RGBA?|HSL|RGB|UTC|ISO|UI|HTTP|HTTP Method|H2D|H2D Pro|X1C|X1E|P1S|P1P|A1|A1 Mini|H2C|N3F|N3S|PETG|PLA|ABS|PA|TPU|PEI|PA-CF|PVA|HIPS|ASA|PC|PETG-HF|G\.code|G-code|gcode|cm³|°C|°F|GCODE|SOURCE|ntfy|Pushover|Telegram|Webhook|Webhook URL|Home Assistant|Home Assistant URL|CallMeBot\/WhatsApp|Bambuddy URL|Cool Plate|Cool Plate SuperTack|Engineering Plate|High Temp Plate|Smooth PEI Plate|Textured PEI Plate|Ext-L|Ext-R|ISO \(YYYY-MM-DD\))$/.test(value)) return true;
+  return false;
+}
+
+// Per-(locale, value) allow-list for strings that are a real word/term in
+// that target locale and so legitimately match en.ts. Curated — add an entry
+// here ONLY after verifying that the word is correct (not just a shortcut to
+// silence the check).
+//
+// Convention: same shape as the locales themselves — { de: Set, fr: Set, ... }.
+// Values are matched exactly. To allow a value across many locales, list it in
+// each one (verbosity is the point: every locale's allow-list is an explicit
+// translator decision).
+// German loanwords / cognates from English are extensive. Most short technical
+// UI labels are identical in DE. List below curates the legitimate ones.
+const DE_COGNATES = [
+  'Name', 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Modus',
+  'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Bug', 'Job',
+  'Pause', 'Power', 'System', 'Problem', 'Designer', 'Extruder', 'Firmware',
+  'Material', 'Original', 'Position', 'Webhook', 'Workflow', 'Slicer',
+  'Region', 'Normal', 'Orange', 'Branch', 'Budget', 'Commit', 'Global',
+  'Version', 'Slot', 'Live', 'Rate', 'Host', 'Trend', 'Min', 'Admin', 'Cloud',
+  'Filament', 'Filaments', 'Software', 'Hardware', 'Avatar', 'Pin', 'Modal',
+  'Active', 'Plate', 'Layer', 'Total', 'Plus', 'Pro', 'Mini', 'Studio',
+  'Temperatur', 'Process', 'Service', 'Cache', 'Color', 'Login', 'Logout',
+  'Action', 'Description', 'Sender', 'Setup', 'Bundle', 'Cluster', 'Tier',
+  'Standard (100%)', 'Sport (124%)', 'Ludicrous (166%)',
+  'Smart Plugs', 'Smart Switches', 'Smart Plug', 'High Flow',
+  'Optional', 'optional', 'Filter', 'Filters', 'optional)',
+  'Material:', 'Default:', 'Name *', '(System)', '(Inv)',
+  'Spoolman URL', 'Bundle', 'Slicer Bundles', 'Imported',
+  'STARTTLS (Port 587)', 'SSL/TLS (Port 465)', 'Sport', 'Standard',
+  'EC984C,#6CD4BC,A66EB9,D87694',
+  'Hex', 'Warm', 'Neutral', 'Navigation', 'Screenshot', 'Architecture',
+  'Backend & Auth', 'Stream Overlay', 'Bambuddy Backend URL',
+  'Material (optional)', 'Custom Headers (JSON)', '({{count}}/8)',
+  'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
+  'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
+  'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+  'China', 'Proxy', 'Start',
+];
+
+// French cognates — many UI labels overlap with English exactly.
+const FR_COGNATES = [
+  'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
+  'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
+  'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar',
+  'Job', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Excellent', 'Description',
+  'Action', 'Actions', 'Date', 'Type', 'Cache', 'Service', 'Configuration',
+  'Archives', 'Maintenance', 'Notifications', 'Notification', 'Position',
+  'Pause', 'Solution', 'Source', 'Version', 'Format', 'Documentation',
+  'Mode', 'Format', 'Default', 'Auto', 'Image', 'Audio', 'Video', 'Hex',
+  'Camera', 'Avatar', 'Information', 'Initialization', 'Inactive', 'Active',
+  'Print', 'Console', 'Cluster', 'Tier', 'Status URL',
+  'Smart Plugs', 'Smart Switches', 'Smart Plug', 'High Flow',
+  'Material:', 'Default:', 'Name *', '(System)', '(Inv)',
+  'Process', 'Service', 'Service', 'Connect', 'Network', 'Local',
+  'Sport (124%)', 'Ludicrous (166%)', 'Standard (100%)',
+  'STARTTLS (Port 587)', 'SSL/TLS (Port 465)',
+  'Bundle', 'Slicer Bundles', 'Imported',
+  'Page', 'Note', 'Tare', 'Est.', 'Cloud', 'Style', 'Notes', 'Stock',
+  'Accent', 'Orange', 'Global', 'Stable', 'Archive', 'visible', 'minutes',
+  'Message', 'Slicer', 'Rotation', 'Original', 'Direction', 'Architecture',
+  'notifications', 'Maintenance OK', 'total', 'Provider', 'Token name',
+  '{{count}} filament', '{{count}} filaments', '{{count}} permissions',
+  '{{count}} downloads', '{{count}} item', '{{count}} selected',
+  '({{count}} item)', 'Provisioning...', 'Pressure Advance',
+  'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
+  'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
+  'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+  '({{count}}/8)', 'Custom Headers (JSON)', 'Permissions',
+  'Expand dispatch details', 'Collapse dispatch details',
+  'Cancelling upload...', 'Backup in progress...', 'Searching directory...',
+  'EC984C,#6CD4BC,A66EB9,D87694',
+  'Proxy', 'Navigation', 'Budget', 'Commit', 'Designer',
+  'ntfy, Pushover, Discord, etc.',
+];
+
+// Italian cognates.
+const IT_COGNATES = [
+  'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
+  'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
+  'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini',
+  'Studio', 'Cache', 'Service', 'Avatar', 'Slicer', 'Action', 'Actions',
+  'Format', 'Modal', 'Login', 'Logout', 'Color', 'Plus', 'Job', 'Live',
+  'Position', 'Original', 'Material', 'Cluster', 'Tier', 'Auto', 'Hex',
+  'Bundle', 'Slicer Bundles', 'Imported', 'Smart Plugs', 'Smart Switches',
+  'Smart Plug', 'High Flow', 'Sport (124%)', 'Ludicrous (166%)',
+  'Standard (100%)', 'STARTTLS (Port 587)', 'SSL/TLS (Port 465)',
+  'Slot', 'Host', 'File', 'Cloud', 'Admin', 'Silk', '(Inv)', 'Slice',
+  'Backup', 'Legacy', 'Branch', 'Auto On', 'Display', 'Password',
+  'Auto Off', 'Dashboard', 'Timestamp', 'Pressure Advance', 'Provisioning...',
+  '(25%, 50%, 75%)', 'Provider', 'Provider: {{type}}', 'Base: {{name}}',
+  'Slicing…', 'Designer', 'Firmware', 'Timelapse', 'Commit', 'Budget',
+  '({{count}}/8)', 'Custom Headers (JSON)', 'ETA {{minutes}} min',
+  '{{name}} - Timelapse', 'Box label (62 × 29 mm)',
+  'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
+  'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+  'AMS holder (30 × 15 mm)', 'Hex: #{{hex}}',
+  'EC984C,#6CD4BC,A66EB9,D87694',
+  'Proxy', 'Designer',
+];
+
+// Japanese: very few cognates because of script difference. Almost
+// everything needs translation. Only true loanwords / proper nouns stay.
+const JA_COGNATES = [
+  'OK', 'Bambu', 'Code',
+  'EU (DD/MM/YYYY)', 'US (MM/DD/YYYY)', 'ON, true, 1',
+  '({{count}}/8)', 'Custom Headers (JSON)',
+  'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
+  'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
+  'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+  'EC984C,#6CD4BC,A66EB9,D87694',
+];
+
+// Portuguese (BR) cognates.
+const PT_BR_COGNATES = [
+  'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
+  'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server',
+  'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Cache',
+  'Service', 'Avatar', 'Total', 'Active', 'Login', 'Logout', 'Color', 'Hex',
+  'Slot', 'Live', 'Rate', 'Host', 'Trend', 'Original', 'Auto', 'Bundle',
+  'Imported', 'Action', 'Actions', 'Slicer Bundles', 'Sport (124%)',
+  'Ludicrous (166%)', 'Standard (100%)', 'STARTTLS (Port 587)',
+  'SSL/TLS (Port 465)', 'Smart Plugs', 'Smart Switches', 'High Flow',
+  'Position', 'Mode', 'Setup', 'Modal',
+  'Local', 'Metal', 'China', 'Admin', 'Silk', 'Backup', '(Inv)', 'Branch',
+  'Normal', 'Material', 'Material:', 'Multicolor', 'Designer', 'Firmware',
+  'Timelapse', 'Est.', 'total', 'Commit', 'Global',
+  'Base: {{name}}', 'ETA {{minutes}} min', '{{count}} item',
+  '{{count}} downloads', '({{count}} item)', '(25%, 50%, 75%)',
+  '({{count}}/8)', 'Custom Headers (JSON)', '{{name}} - Timelapse',
+  'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
+  'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
+  'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+  'Cancelling upload...', 'EC984C,#6CD4BC,A66EB9,D87694',
+  'Expand dispatch details', 'Collapse dispatch details',
+  'e.g., Home Assistant, OctoPrint', 'ntfy, Pushover, Discord, etc.',
+  'Proxy', 'total: {{minutes}} min',
+];
+
+// Chinese (Simplified): very few cognates beyond brand names.
+const ZH_CN_COGNATES = [
+  'OK', 'Bambu',
+  '({{count}}/8)', 'Custom Headers (JSON)', 'AMS holder (30 × 15 mm)',
+  'Box label (62 × 29 mm)',
+  'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
+  'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+  'EC984C,#6CD4BC,A66EB9,D87694',
+];
+
+const ZH_TW_COGNATES = [
+  'OK', 'Bambu',
+  '({{count}}/8)', 'Custom Headers (JSON)', 'AMS holder (30 × 15 mm)',
+  'Box label (62 × 29 mm)',
+  'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
+  'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+  'EC984C,#6CD4BC,A66EB9,D87694',
+];
+
+const IDENTICAL_TO_EN_ALLOWED = {
+  de: new Set(DE_COGNATES),
+  fr: new Set(FR_COGNATES),
+  it: new Set(IT_COGNATES),
+  ja: new Set(JA_COGNATES),
+  'pt-BR': new Set(PT_BR_COGNATES),
+  'zh-CN': new Set(ZH_CN_COGNATES),
+  'zh-TW': new Set(ZH_TW_COGNATES),
+};
+
 // Pure comparison logic, exported so tests can verify each failure mode
 // without going through file IO or the TypeScript parser.
 // Input:  locales = { code: Map<leafKey, leafString> }  (must contain 'en')
@@ -158,6 +352,26 @@ export function compareLocales(locales) {
     add(`${code}: plural key mismatch`, pluralIssues);
   }
 
+  // Check 4: identical-to-en leaks. A non-English leaf whose value exactly
+  // matches en.ts must either pass the always-allowed heuristic OR be listed
+  // in IDENTICAL_TO_EN_ALLOWED[code]. Otherwise it's almost certainly an
+  // untranslated English string that slipped through past parity gates.
+  for (const [code, map] of Object.entries(locales)) {
+    if (code === 'en') continue;
+    const allowed = IDENTICAL_TO_EN_ALLOWED[code] ?? new Set();
+    const leaks = [];
+    for (const [key, enValue] of locales.en) {
+      const localeValue = map.get(key);
+      if (localeValue === undefined) continue;
+      if (localeValue !== enValue) continue;
+      if (isAlwaysAllowedIdentical(enValue)) continue;
+      if (allowed.has(enValue)) continue;
+      const preview = enValue.length > 60 ? `${enValue.slice(0, 57)}...` : enValue;
+      leaks.push(`${key}: "${preview}"`);
+    }
+    add(`${code}: leaves identical to en (untranslated?)`, leaks);
+  }
+
   return { failed: reports.length > 0, reports };
 }
 

+ 76 - 0
frontend/scripts/dump-untranslated.mjs

@@ -0,0 +1,76 @@
+// Dump every (locale, dottedKey, enValue) tuple that the parity check would
+// flag as "identical to en, not in allow-list, not auto-allowed". Output is
+// JSON, structured so apply-translations.mjs can consume the same shape after
+// translations are filled in.
+//
+// Usage:  node scripts/dump-untranslated.mjs > /tmp/untranslated.json
+//
+// Logic is imported from check-i18n-parity.mjs (compareLocales) so this stays
+// in lockstep with the gate.
+
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import url from 'node:url';
+
+const scriptDir = path.dirname(url.fileURLToPath(import.meta.url));
+const localesDir = path.resolve(scriptDir, '../src/i18n/locales');
+const tsPath = path.resolve(scriptDir, '../node_modules/typescript/lib/typescript.js');
+const tsModule = await import(url.pathToFileURL(tsPath).href);
+const ts = tsModule.default ?? tsModule;
+
+const { compareLocales } = await import(url.pathToFileURL(path.join(scriptDir, 'check-i18n-parity.mjs')).href);
+
+function collectLeaves(node, prefix, leaves) {
+  if (!ts.isObjectLiteralExpression(node)) return;
+  for (const prop of node.properties) {
+    if (!ts.isPropertyAssignment(prop)) continue;
+    let name;
+    if (ts.isIdentifier(prop.name)) name = prop.name.text;
+    else if (ts.isStringLiteral(prop.name)) name = prop.name.text;
+    else continue;
+    const p = prefix ? `${prefix}.${name}` : name;
+    if (ts.isObjectLiteralExpression(prop.initializer)) {
+      collectLeaves(prop.initializer, p, leaves);
+    } else if (ts.isStringLiteral(prop.initializer) || ts.isNoSubstitutionTemplateLiteral(prop.initializer)) {
+      leaves.set(p, prop.initializer.text);
+    }
+  }
+}
+function loadLocale(filePath) {
+  const sf = ts.createSourceFile(filePath, fs.readFileSync(filePath, 'utf8'), ts.ScriptTarget.Latest, true);
+  const leaves = new Map();
+  ts.forEachChild(sf, (n) => { if (ts.isExportAssignment(n)) collectLeaves(n.expression, '', leaves); });
+  return leaves;
+}
+
+const codes = ['en', 'de', 'fr', 'it', 'ja', 'pt-BR', 'zh-CN', 'zh-TW'];
+const locales = Object.fromEntries(codes.map((c) => [c, loadLocale(path.join(localesDir, `${c}.ts`))]));
+const { reports } = compareLocales(locales);
+
+// Reports look like: { label: 'de: leaves identical to en (untranslated?)', items: ['<key>: "<value>"', ...] }
+// We need to reverse-engineer the (locale, key, enValue) tuples — easier to
+// just walk en and check each locale ourselves with the live ALLOWED, which
+// is what compareLocales does anyway. So mirror that here.
+const en = locales.en;
+const out = {};
+for (const code of codes) {
+  if (code === 'en') continue;
+  const map = locales[code];
+  const flagged = {};
+  for (const r of reports) {
+    if (r.label !== `${code}: leaves identical to en (untranslated?)`) continue;
+    for (const item of r.items) {
+      // Item format:  "<dottedKey>: "<value>""
+      const m = item.match(/^(\S+):\s+"(.*)"$/);
+      if (!m) continue;
+      const key = m[1];
+      const enValue = en.get(key);
+      if (enValue !== undefined && map.get(key) === enValue) {
+        flagged[key] = enValue;
+      }
+    }
+  }
+  out[code] = flagged;
+}
+process.stdout.write(JSON.stringify(out, null, 2));

+ 86 - 0
frontend/scripts/expand-translations.mjs

@@ -0,0 +1,86 @@
+// Expand a "source-string → per-locale translations" map into the
+// per-(locale, dottedKey) shape that apply-translations.mjs consumes.
+//
+// Usage:  node scripts/expand-translations.mjs <source-table.json> > out.json
+//
+// Source-table shape (one entry per unique English source string):
+//   {
+//     "Status": { "de": "Status", "fr": "Statut", "it": "Stato", "ja": "ステータス", "pt-BR": "Status", "zh-CN": "状态", "zh-TW": "狀態" },
+//     ...
+//   }
+//
+// Reads the current untranslated set from the live locale files (same logic
+// as dump-untranslated.mjs) and outputs:
+//   { "de": { "<dottedKey>": "<translation>", ... }, "fr": {...}, ... }
+
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import url from 'node:url';
+
+const scriptDir = path.dirname(url.fileURLToPath(import.meta.url));
+const localesDir = path.resolve(scriptDir, '../src/i18n/locales');
+const tsPath = path.resolve(scriptDir, '../node_modules/typescript/lib/typescript.js');
+const tsModule = await import(url.pathToFileURL(tsPath).href);
+const ts = tsModule.default ?? tsModule;
+
+function collectLeaves(node, prefix, leaves) {
+  if (!ts.isObjectLiteralExpression(node)) return;
+  for (const prop of node.properties) {
+    if (!ts.isPropertyAssignment(prop)) continue;
+    let name;
+    if (ts.isIdentifier(prop.name)) name = prop.name.text;
+    else if (ts.isStringLiteral(prop.name)) name = prop.name.text;
+    else continue;
+    const p = prefix ? `${prefix}.${name}` : name;
+    if (ts.isObjectLiteralExpression(prop.initializer)) {
+      collectLeaves(prop.initializer, p, leaves);
+    } else if (ts.isStringLiteral(prop.initializer) || ts.isNoSubstitutionTemplateLiteral(prop.initializer)) {
+      leaves.set(p, prop.initializer.text);
+    }
+  }
+}
+function loadLocale(filePath) {
+  const sf = ts.createSourceFile(filePath, fs.readFileSync(filePath, 'utf8'), ts.ScriptTarget.Latest, true);
+  const leaves = new Map();
+  ts.forEachChild(sf, (n) => { if (ts.isExportAssignment(n)) collectLeaves(n.expression, '', leaves); });
+  return leaves;
+}
+
+const arg = process.argv[2];
+if (!arg) {
+  console.error('Usage: node expand-translations.mjs <source-table.json>');
+  process.exit(2);
+}
+const table = JSON.parse(fs.readFileSync(arg, 'utf8'));
+const en = loadLocale(path.join(localesDir, 'en.ts'));
+
+const codes = ['de', 'fr', 'it', 'ja', 'pt-BR', 'zh-CN', 'zh-TW'];
+const out = Object.fromEntries(codes.map((c) => [c, {}]));
+const missingSources = new Set();
+
+for (const code of codes) {
+  const map = loadLocale(path.join(localesDir, `${code}.ts`));
+  for (const [key, enValue] of en) {
+    const localeValue = map.get(key);
+    if (localeValue === undefined) continue;
+    if (localeValue !== enValue) continue;  // already translated
+    const entry = table[enValue];
+    if (!entry) { missingSources.add(enValue); continue; }
+    const translated = entry[code];
+    if (translated === undefined) continue;
+    // Always emit, even when translated === enValue, so the apply script can
+    // either no-op or replace as needed. (Same value is a no-op via .text check.)
+    out[code][key] = translated;
+  }
+}
+
+if (missingSources.size > 0) {
+  process.stderr.write(`\n[warn] ${missingSources.size} source strings not in table:\n`);
+  for (const s of [...missingSources].sort()) {
+    const preview = s.length > 80 ? s.slice(0, 77) + '...' : s;
+    process.stderr.write(`  ${JSON.stringify(preview)}\n`);
+  }
+}
+
+process.stdout.write(JSON.stringify(out, null, 2));

+ 144 - 144
frontend/src/i18n/locales/de.ts

@@ -16,7 +16,7 @@ export default {
     system: 'System',
     collapseSidebar: 'Seitenleiste einklappen',
     expandSidebar: 'Seitenleiste ausklappen',
-    update: 'Update',
+    update: 'Aktualisieren',
     updateAvailable: 'Update verfügbar: v{{version}}',
     updateAvailableBanner: 'Version {{version}} ist verfügbar!',
     viewUpdate: 'Update anzeigen',
@@ -262,13 +262,13 @@ export default {
     nozzleStatus: 'Status',
     nozzleFilament: 'Filament',
     nozzleWear: 'Verschleiß',
-    nozzleMaxTemp: 'Max Temp',
+    nozzleMaxTemp: 'Max. Temp',
     nozzleSerial: 'Seriennr.',
     nozzleHardenedSteel: 'Gehärteter Stahl',
     nozzleStainlessSteel: 'Edelstahl',
     nozzleTungstenCarbide: 'Wolframkarbid',
     nozzleFlow: 'Durchfluss',
-    nozzleHighFlow: 'High Flow',
+    nozzleHighFlow: 'Hoher Durchfluss',
     nozzleStandardFlow: 'Standard',
     // Firmware
     firmwareUpdate: 'Firmware-Update',
@@ -511,7 +511,7 @@ export default {
     // Firmware
     firmwareUpdateAvailable: 'Firmware-Update verfügbar: {{current}} → {{latest}}',
     firmwareUpToDate: 'Firmware {{version}} — Aktuell',
-    firmwareUpdateButton: 'Update',
+    firmwareUpdateButton: 'Aktualisieren',
     // Plate detection
     plateDetection: {
       noPermission: 'Sie haben keine Berechtigung, Drucker zu aktualisieren',
@@ -792,7 +792,7 @@ export default {
       estimated: 'Geschätzt: {{time}}',
       actual: 'Tatsächlich: {{time}}',
       accuracy: 'Genauigkeit: {{percent}}%',
-      filament: '{{weight}}g',
+      filament: '{{weight}} g',
       layer: '{{count}} Schicht',
       layers: '{{count}} Schichten',
       object: '{{count}} Objekt',
@@ -810,7 +810,7 @@ export default {
       openInBambuStudioToSlice: 'Im Slicer öffnen zum Slicen',
       slice: 'Slicen',
       externalLink: 'Externer Link',
-      makerWorld: 'MakerWorld: {{designer}}',
+      makerWorld: 'MakerWorld {{designer}}',
       viewProject: 'Projekt ansehen',
       noExternalLink: 'Kein externer Link',
       preview3d: '3D-Vorschau',
@@ -1019,7 +1019,7 @@ export default {
       staged: 'Bereitgestellt',
       requiresPrevious: 'Erfordert vorherigen Erfolg',
       autoPowerOff: 'Automatisch ausschalten',
-      gcodeInjection: 'G-code',
+      gcodeInjection: 'G-Code',
     },
     // Empty state
     empty: {
@@ -1550,7 +1550,7 @@ export default {
     notifications: 'Benachrichtigungen',
     smartPlugs: 'Smart Plugs',
     spoolman: 'Spoolman',
-    updates: 'Updates',
+    updates: 'Aktualisierungen',
     language: 'Sprache',
     languageDescription: 'Wählen Sie Ihre bevorzugte Sprache',
     theme: 'Design',
@@ -1816,22 +1816,22 @@ export default {
     defaultLayerInspectDesc: 'KI-Inspektion der ersten Schicht',
     defaultTimelapse: 'Zeitraffer',
     defaultTimelapseDesc: 'Zeitraffervideo aufnehmen',
-    staggeredStart: 'Staggered Start',
-    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggeredStart: 'Versetzter Start',
+    staggeredStartDescription: 'Standard-Gruppengröße und -Intervall beim Staffeln von Mehrdrucker-Batchstarts. Pro Batch im Druck-Dialog überschreibbar.',
     plateClear: 'Druckplatte-Bestätigung',
     requirePlateClear: 'Druckplatte-Bestätigung erforderlich',
     requirePlateClearDescription: 'Wenn aktiviert, wartet der Scheduler auf eine Druckplatten-Bestätigung pro Drucker, bevor geplante Drucke auf Druckern mit abgeschlossenen Aufträgen gestartet werden. Wenn dies deaktiviert ist, werden auch das Druckplatten-Status-Badge und die Schaltfläche "Druckplatte als freigegeben markieren" auf den Druckerkarten ausgeblendet.',
-    gcodeInjection: 'G-code Injection',
+    gcodeInjection: 'G-Code-Injektion',
     gcodeInjectionDescription: 'Konfigurieren Sie benutzerdefinierten G-code, der am Anfang und/oder Ende von Drucken für Auto-Print-Systeme wie Farmloop, SwapMod, AutoClear und Printflow 3D eingefügt wird. Snippets werden pro Druckermodell konfiguriert und angewendet, wenn "G-code einfügen" bei einem Warteschlangen-Element aktiviert ist.',
     gcodeInjectionNoPrinters: 'Keine Drucker gefunden. Fügen Sie Drucker hinzu, um G-code-Snippets zu konfigurieren.',
-    gcodeStartLabel: 'Start G-code',
-    gcodeEndLabel: 'End G-code',
+    gcodeStartLabel: 'Start-G-Code',
+    gcodeEndLabel: 'End-G-Code',
     gcodeStartPlaceholder: 'G-code, der vor dem Druckstart eingefügt wird...',
     gcodeEndPlaceholder: 'G-code, der nach dem Druckende angefügt wird...',
-    staggerGroupSize: 'Group size',
-    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
-    staggerInterval: 'Interval (minutes)',
-    staggerIntervalHelp: 'Delay between each group starting',
+    staggerGroupSize: 'Gruppengröße',
+    staggerGroupSizeHelp: 'Anzahl gleichzeitig zu startender Drucker pro Gruppe',
+    staggerInterval: 'Intervall (Minuten)',
+    staggerIntervalHelp: 'Verzögerung zwischen Gruppenstart',
     queueDrying: 'Automatische Trocknung',
     queueDryingDescription: 'AMS-Filament automatisch trocknen, wenn der Drucker zwischen Warteschlangen-Drucken im Leerlauf ist. Verwendet den Feuchtigkeitsschwellenwert oben.',
     queueDryingEnabled: 'Automatische Trocknung aktivieren',
@@ -2054,7 +2054,7 @@ export default {
       deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen?',
       resetCatalog: 'Farbkatalog zurücksetzen',
       resetConfirm: 'Katalog auf Standardwerte zurücksetzen? Alle benutzerdefinierten Farben werden entfernt.',
-      sync: 'Sync',
+      sync: 'Synchron.',
       starting: 'Starten...',
       syncTooltip: 'Von FilamentColors.xyz synchronisieren (2000+ Farben)',
       loadFailed: 'Farbkatalog konnte nicht geladen werden',
@@ -2164,7 +2164,7 @@ export default {
     // Updates
     printerFirmware: 'Drucker-Firmware',
     checkFirmwareDescription: 'Nach Firmware-Updates von Bambu Lab suchen',
-    bambuddySoftware: 'Bambuddy Software',
+    bambuddySoftware: 'Bambuddy-Software',
     autoCheckDescription: 'Automatisch beim Start nach neuen Versionen suchen',
     checkNow: 'Jetzt prüfen',
     updateAvailableVersion: 'Update verfügbar: v{{version}}',
@@ -2310,7 +2310,7 @@ export default {
         issuerUrl: 'Aussteller-URL',
         clientId: 'Client-ID',
         clientSecret: 'Client-Secret',
-        scopes: 'Scopes',
+        scopes: 'Bereiche',
         iconUrl: 'Symbol-URL (optional)',
         enabled: 'Aktiviert',
         autoCreate: 'Benutzer automatisch anlegen',
@@ -2321,7 +2321,7 @@ export default {
         secretPlaceholder: 'neues Secret',
         emailClaim: 'E-Mail-Claim',
         emailClaimDesc: "JWT-Claim für die E-Mail-Identität. Für Azure Entra ID 'preferred_username' oder 'upn' verwenden (sendet kein email_verified). Nur vertrauenswürdige Claim-Namen verwenden.",
-        emailClaimPlaceholder: 'email',
+        emailClaimPlaceholder: 'E-Mail',
         emailClaimCustomClaimAutoLinkWarning: "Benutzerdefinierte Claims sind für die Auto-Verknüpfung nur sicher, wenn der Wert vom Mandanten verwaltet wird (z. B. Azure Entra ID upn / preferred_username). Aktiviere Auto-Verknüpfung nicht, wenn dein IdP Benutzern erlaubt, diesen Claim selbst zu setzen.",
         requireEmailVerified: 'E-Mail-Verifizierung erforderlich',
         requireEmailVerifiedDesc: 'E-Mail-Claim nur akzeptieren, wenn der Provider ihn als verifiziert markiert.',
@@ -2380,7 +2380,7 @@ export default {
     },
     printerError: {
       title: 'Druckerfehler',
-      body: '{{printer}}: {{error}}',
+      body: '{{printer}} {{error}}',
     },
     filamentLow: {
       title: 'Filament niedrig',
@@ -2432,7 +2432,7 @@ export default {
     startLogging: 'Protokollierung starten',
     stopLogging: 'Protokollierung stoppen',
     clearLog: 'Protokoll löschen',
-    topic: 'Topic',
+    topic: 'Thema',
     timestamp: 'Zeitstempel',
     direction: 'Richtung',
     all: 'Alle',
@@ -3402,12 +3402,12 @@ export default {
   // Slice (slicer-API integration via SliceModal)
   slice: {
     title: 'Modell slicen',
-    action: 'Slice',
+    action: 'Slicen',
     slicing: 'Slicen…',
     printer: 'Drucker-Profil',
     process: 'Prozess-Profil',
     filament: 'Filament-Profil',
-    filamentSlot: 'Filament {{index}} ({{type}})',
+    filamentSlot: 'Filament {{index}} – {{type}}',
     selectPreset: '— Profil auswählen —',
     loadingPresets: 'Profile werden geladen…',
     analyzingPlateFilaments: 'Plattenfilamente werden analysiert…',
@@ -3425,7 +3425,7 @@ export default {
     startedToast: '{{name}} wird im Hintergrund gesliced…',
     queuedToast: 'Warteschlange: {{name}} — {{elapsed}}',
     runningToast: '{{name}} wird gesliced — {{elapsed}}',
-    runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
+    runningWithProgress: '{{name}} – {{stage}} ({{percent}} %) – {{elapsed}}',
     completedToast: '{{name}} wurde gesliced',
     failedToast: 'Slicen von {{name}} fehlgeschlagen: {{detail}}',
     tier: {
@@ -3493,26 +3493,26 @@ export default {
     spoolmanMixedContentFixOpenNewTab: 'Als Workaround kannst du Spoolman in einem neuen Tab über HTTP öffnen — gemischte Inhalte werden nur innerhalb eingebetteter Frames blockiert, ein eigener Tab funktioniert weiterhin.',
     spoolmanOpenInNewTab: 'Spoolman in neuem Tab öffnen',
     labels: {
-      title: 'Print spool labels',
-      selectedCount: '{{count}} selected',
-      pickSpools: 'Pick which spools to print labels for:',
-      searchPlaceholder: 'Search name, brand, or #ID',
+      title: 'Spulen-Etiketten drucken',
+      selectedCount: '{{count}} ausgewählt',
+      pickSpools: 'Wählen Sie, für welche Spulen Etiketten gedruckt werden sollen:',
+      searchPlaceholder: 'Name, Marke oder #ID suchen',
       filterByMaterial: 'Material:',
-      allMaterials: 'All',
-      selectVisible: 'Select all visible ({{count}})',
-      deselectVisible: 'Deselect visible',
-      clearAll: 'Clear all',
-      noSpoolsToShow: 'No spools to show. Adjust your filter and try again.',
-      noMatches: 'No spools match the current search or filter.',
-      printOne: 'Print label for this spool',
-      printLabels: 'Print labels…',
-      bulkTitle: 'Pick spools to print labels for from the {{count}} currently shown',
-      noSpoolsTitle: 'No spools to label',
-      error: 'Could not generate labels: {{msg}}',
+      allMaterials: 'Alle',
+      selectVisible: 'Alle sichtbaren auswählen ({{count}})',
+      deselectVisible: 'Sichtbare abwählen',
+      clearAll: 'Alle entfernen',
+      noSpoolsToShow: 'Keine Spulen anzuzeigen. Filter anpassen und erneut versuchen.',
+      noMatches: 'Keine Spulen entsprechen der aktuellen Suche oder dem Filter.',
+      printOne: 'Etikett für diese Spule drucken',
+      printLabels: 'Etiketten drucken…',
+      bulkTitle: 'Spulen aus den aktuell angezeigten {{count}} zum Etikettieren auswählen',
+      noSpoolsTitle: 'Keine Spulen zum Etikettieren',
+      error: 'Etiketten konnten nicht erstellt werden: {{msg}}',
       templates: {
         ams: {
           label: 'AMS holder (30 × 15 mm)',
-          hint: 'Single label per page; fits the popular AMS filament label holder.',
+          hint: 'Ein Etikett pro Seite; passt in den beliebten AMS-Filament-Etikettenhalter.',
         },
         box40x30: {
           label: 'Boxetikett (40 × 30 mm)',
@@ -3520,15 +3520,15 @@ export default {
         },
         box: {
           label: 'Box label (62 × 29 mm)',
-          hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+          hint: 'Ein Etikett pro Seite; für Brother PT/QL und Dymo-Kleinetiketten dimensioniert.',
         },
         averyL7160: {
           label: 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
-          hint: 'EU sheet stock; 21 labels per A4 page.',
+          hint: 'EU-Bogenformat; 21 Etiketten pro A4-Seite.',
         },
         avery5160: {
           label: 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
-          hint: 'US sheet stock; 30 labels per Letter page.',
+          hint: 'US-Bogenformat; 30 Etiketten pro Letter-Seite.',
         },
       },
     },
@@ -3543,7 +3543,7 @@ export default {
     useCustomBrand: '"{{brand}}" verwenden',
     useCustomMaterial: 'Benutzerdefiniertes Material verwenden: {{material}}',
     colorName: 'Farbname',
-    colorNamePlaceholder: 'Jade White, Fire Red...',
+    colorNamePlaceholder: 'Jadeweiß, Feuerrot...',
     color: 'Farbe',
     hexColor: 'Hex-Farbe',
     pickColor: 'Benutzerdefinierte Farbe wählen',
@@ -3657,7 +3657,7 @@ export default {
       matte: 'Matt',
       // Glanz- / Finish-Varianten
       silk: 'Seide',
-      galaxy: 'Galaxy',
+      galaxy: 'Galaxie',
       rainbow: 'Regenbogen',
       metal: 'Metallic',
       translucent: 'Lichtdurchlässig',
@@ -3835,12 +3835,12 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}}: benötigt {{required}}g, verbleibend {{remaining}}g',
     printAnyway: 'Trotzdem drucken',
     forceColorMatch: 'Farbe erzwingen',
-    staggerPrinterStarts: 'Stagger printer starts',
-    staggerGroupSize: 'Group size',
-    staggerInterval: 'Interval (min)',
-    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
-    staggerLastGroup: 'last group: {{count}}',
-    staggerTotal: 'total: {{minutes}} min',
+    staggerPrinterStarts: 'Druckerstarts staffeln',
+    staggerGroupSize: 'Gruppengröße',
+    staggerInterval: 'Intervall (Min.)',
+    staggerPreview: '{{printers}} Drucker → {{groups}} Gruppen à {{size}}, Start alle {{interval}} Min.',
+    staggerLastGroup: 'letzte Gruppe: {{count}}',
+    staggerTotal: 'insgesamt: {{minutes}} Min.',
     staggerToPrinters: 'Gestaffelt an {{count}} Drucker senden',
     gcodeInjection: 'Auto-Print G-code einfügen',
   },
@@ -3868,7 +3868,7 @@ export default {
     restoreNote: 'Virtueller Drucker wird während der Wiederherstellung gestoppt',
 
     // GitHub Backup
-    githubBackup: 'Git Backup',
+    githubBackup: 'Git-Backup',
     enabled: 'Aktiviert',
     cloudLoginRequired: 'Bambu Cloud Login erforderlich. Melden Sie sich unter Profile → Cloud-Profile an, um GitHub-Backup zu aktivieren.',
     cloudLoginRequiredShort: 'Cloud-Login erforderlich',
@@ -3885,7 +3885,7 @@ export default {
     enterNewToken: 'Neuen Token eingeben zum Aktualisieren',
     tokenHint: 'Feingranularer Token mit Lese-/Schreibberechtigung für Inhalte',
     branch: 'Branch',
-    provider: 'Git Provider',
+    provider: 'Git-Anbieter',
     providerGitHub: 'GitHub',
     providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
@@ -4005,27 +4005,27 @@ export default {
     close: 'Schließen',
 
     // Scheduled local backups (#884)
-    scheduledBackup: 'Scheduled Backups',
-    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',
-    frequency: 'Frequency',
-    backupTime: 'Time',
-    retention: 'Retention',
-    retentionDescription: 'Number of backups to keep',
-    outputPath: 'Output Path',
-    outputPathPlaceholder: 'Default: {{path}}',
-    outputPathDescription: 'Leave empty for default location',
-    runNow: 'Run Now',
-    backupFiles: 'Backup Files',
-    noScheduledBackups: 'No backups yet',
-    deleteBackup: 'Delete',
-    deleteBackupConfirm: 'Delete this backup file?',
-    backupRunning: 'Backup in progress...',
-    scheduledBackupComplete: 'Backup completed successfully',
-    scheduledBackupFailed: 'Backup failed',
-    nextBackup: 'Next backup',
-    backupSize: 'Size',
+    scheduledBackup: 'Geplante Backups',
+    scheduledBackupDescription: 'Backup-Snapshots automatisch nach Zeitplan erstellen. Ausgabeverzeichnis kann auf einen NAS oder externen Speicher gemountet werden.',
+    frequency: 'Frequenz',
+    backupTime: 'Zeit',
+    retention: 'Aufbewahrung',
+    retentionDescription: 'Anzahl der zu behaltenden Backups',
+    outputPath: 'Ausgabepfad',
+    outputPathPlaceholder: 'Standard: {{path}}',
+    outputPathDescription: 'Leer lassen für den Standardort',
+    runNow: 'Jetzt ausführen',
+    backupFiles: 'Backup-Dateien',
+    noScheduledBackups: 'Noch keine Backups',
+    deleteBackup: 'Löschen',
+    deleteBackupConfirm: 'Diese Backup-Datei löschen?',
+    backupRunning: 'Backup läuft...',
+    scheduledBackupComplete: 'Backup erfolgreich abgeschlossen',
+    scheduledBackupFailed: 'Backup fehlgeschlagen',
+    nextBackup: 'Nächstes Backup',
+    backupSize: 'Größe',
     utc: 'UTC',
-    defaultPathLabel: 'Default:',
+    defaultPathLabel: 'Standard:',
 
     // Category labels
     categories: {
@@ -4175,7 +4175,7 @@ export default {
       selectFilament: 'Filament auswählen...',
       noFilamentsHelp: 'Keine Filamente gefunden. Erstellen Sie zuerst ein K-Profil in Bambu Studio.',
       flowType: 'Flusstyp',
-      highFlow: 'High Flow',
+      highFlow: 'Hoher Durchfluss',
       standard: 'Standard',
       nozzleSize: 'Düsengröße',
       extruder: 'Extruder',
@@ -4464,7 +4464,7 @@ export default {
     autoOffPersistentDescription: 'Zwischen Drucken aktiviert bleiben statt einmalig',
     turnOffDelayMode: 'Ausschaltverzögerungsmodus',
     time: 'Zeit',
-    temp: 'Temp',
+    temp: 'Temp.',
     delayMinutes: 'Verzögerung (Minuten)',
     tempThreshold: 'Temperaturschwelle (°C)',
     tempThresholdDescription: 'Schaltet aus wenn die Düse unter diese Temperatur abkühlt',
@@ -4536,7 +4536,7 @@ export default {
     energyMonitoring: 'Energieüberwachung',
     stateMonitoring: 'Statusüberwachung',
     optional: 'optional',
-    topic: 'Topic',
+    topic: 'Thema',
     jsonPath: 'JSON-Pfad',
     multiplier: 'Multiplikator',
     onValue: 'EIN-Wert',
@@ -4544,32 +4544,32 @@ export default {
     mqttEnergyHint: 'JSON-Pfad extrahiert Wert aus JSON-Payload. Leer lassen für rohe Werte.\nMultiplikator 0.001 für Wh→kWh, 1000 für MWh→kWh verwenden.',
     mqttStateHint: 'JSON-Pfad extrahiert Wert aus JSON-Payload. Leer lassen für rohe Werte.\nEIN-Wert: der genaue String der "EIN" bedeutet. Leer lassen für Auto-Erkennung (ON, true, 1).',
     // REST smart plug
-    restControl: 'Control',
-    restOnUrl: 'Turn ON URL',
-    restOffUrl: 'Turn OFF URL',
-    restOnBody: 'ON Request Body',
-    restOffBody: 'OFF Request Body',
-    restMethod: 'HTTP Method',
+    restControl: 'Steuerung',
+    restOnUrl: 'Einschalt-URL',
+    restOffUrl: 'Ausschalt-URL',
+    restOnBody: 'EIN-Anforderungstext',
+    restOffBody: 'AUS-Anforderungstext',
+    restMethod: 'HTTP-Methode',
     restHeaders: 'Custom Headers (JSON)',
-    restStatusUrl: 'Status URL',
-    restStatusPath: 'State JSON Path',
-    restStatusOnValue: 'ON Value',
-    restPowerUrl: 'Power URL',
-    restPowerPath: 'Power JSON Path',
+    restStatusUrl: 'Status-URL',
+    restStatusPath: 'JSON-Pfad für Status',
+    restStatusOnValue: 'EIN-Wert',
+    restPowerUrl: 'Strom-URL',
+    restPowerPath: 'JSON-Pfad für Leistung',
     restPowerMultiplier: 'Power Multiplikator',
     restEnergyUrl: 'Energie URL',
-    restEnergyPath: 'Energy JSON Path',
+    restEnergyPath: 'JSON-Pfad für Energie',
     restEnergyMultiplier: 'Energie Multiplikator',
-    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
-    restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
-    restBodyHint: 'e.g. ON, {"state": "on"}',
-    restStatusHint: 'URL to poll for current state',
-    restPathHint: 'e.g. state or data.power.status',
+    restUrlRequired: 'Mindestens eine URL (ON oder OFF) ist für REST-Steckdosen erforderlich',
+    restHeadersHint: 'z. B. {"Authorization": "Bearer your-token"}',
+    restBodyHint: 'z. B. ON, {"state": "on"}',
+    restStatusHint: 'URL zum Abfragen des aktuellen Status',
+    restPathHint: 'z. B. state oder data.power.status',
     restPowerUrlHint: 'Eigene URL für Leistungsdaten (nutzt Status URL wenn leer)',
     restEnergyUrlHint: 'Eigene URL für Energiedaten (nutzt Status URL wenn leer)',
     restEnergyHint: 'Jeder Wert kann eine eigene URL verwenden oder auf die Status URL zurückgreifen. Multiplikatoren für Einheitenumrechnung verwenden (z.B. 0.001 für Wh zu kWh).',
-    testConnection: 'Test Connection',
-    connectionSuccess: 'Connection successful',
+    testConnection: 'Verbindung testen',
+    connectionSuccess: 'Verbindung erfolgreich',
     noSwitchesInSwitchbar: 'Keine Schalter in der Schaltleiste',
     enableSwitchbarHint: '"In Schaltleiste anzeigen" unter Einstellungen > Smart Plugs aktivieren',
   },
@@ -4711,13 +4711,13 @@ export default {
     sendDigestAt: 'Zusammenfassung senden um',
     digestCollected: 'Ereignisse werden gesammelt und als einzelne Zusammenfassung zu dieser Zeit gesendet',
     notificationEvents: 'Benachrichtigungsereignisse',
-    progressPercent: '(25%, 50%, 75%)',
+    progressPercent: '(25 %, 50 %, 75 %)',
     bedCooledAfterPrint: '(nach Druckabschluss)',
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: 'ntfy-Priorität',
       helpNtfy: 'Wähle eine Priorität pro aktiviertem Ereignis. ntfy nutzt diese, um Hinweise (Ton, Sichtbarkeit, Push-Verhalten) zu eskalieren. Hier nicht gesetzte Stufen verwenden den ntfy-Server-Standard.',
-      min: 'Min',
+      min: 'Min.',
       low: 'Niedrig',
       default: 'Standard',
       high: 'Hoch',
@@ -5006,7 +5006,7 @@ export default {
     title: 'AMS-Slot konfigurieren',
     slotConfigured: 'Slot konfiguriert!',
     configuringSlot: 'Slot wird konfiguriert:',
-    slotLabel: '{{ams}} Slot {{slot}}',
+    slotLabel: '{{ams}} Slot {{slot}}',
     searchPresets: 'Voreinstellungen suchen...',
     colorPlaceholder: 'Farbname oder Hex (z.B. braun, FF8800)',
     clearCustomColor: 'Benutzerdefinierte Farbe löschen',
@@ -5028,7 +5028,7 @@ export default {
     showLessColors: 'Weniger Farben anzeigen',
     showMoreColors: 'Mehr Farben anzeigen',
     clear: 'Löschen',
-    hexLabel: 'Hex: #{{hex}}',
+    hexLabel: 'Hex #{{hex}}',
     resetting: 'Wird zurückgesetzt...',
     resetSlot: 'Slot zurücksetzen',
     cancel: 'Abbrechen',
@@ -5048,7 +5048,7 @@ export default {
   // Email Settings
   emailSettings: {
     placeholders: {
-      fromName: 'BamBuddy',
+      fromName: 'Bambuddy',
     },
   },
 
@@ -5177,17 +5177,17 @@ export default {
     justNow: 'Gerade eben',
     now: 'Jetzt',
     minsAgo: 'vor {{count}}m',
-    inMins: 'in {{count}}m',
+    inMins: 'in {{count}}min',
     hoursAgo: 'vor {{count}}h',
-    inHours: 'in {{count}}h',
+    inHours: 'in {{count}}Std',
     daysAgo: 'vor {{count}}d',
-    inDays: 'in {{count}}d',
+    inDays: 'in {{count}}T',
   },
 
   // SpoolBuddy Kiosk
   spoolbuddy: {
     nav: {
-      dashboard: 'Dashboard',
+      dashboard: 'Übersicht',
       ams: 'AMS',
       inventory: 'Inventar',
       writeTag: 'Schreiben',
@@ -5293,12 +5293,12 @@ export default {
       tabDevice: 'Gerät',
       tabDisplay: 'Anzeige',
       tabScale: 'Waage',
-      tabUpdates: 'Updates',
+      tabUpdates: 'Aktualisierungen',
       // Device tab
       nfcReader: 'NFC-Leser',
       type: 'Typ',
       connection: 'Verbindung',
-      notConnected: 'N/A',
+      notConnected: 'N/V',
       deviceInfo: 'Geräteinfo',
       hostname: 'Host',
       uptime: 'Betriebszeit',
@@ -5648,54 +5648,54 @@ export default {
     saveFailed: 'Einstellungen konnten nicht gespeichert werden.',
   },
   cameraTokens: {
-    title: 'Camera API Tokens',
-    navTitle: 'Camera API tokens',
+    title: 'Kamera-API-Tokens',
+    navTitle: 'Kamera-API-Tokens',
     description:
-      'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.',
-    loading: 'Loading…',
+      'Langlebige Tokens zum Einbetten des Kamerastreams in Home Assistant, Frigate, Kioske oder andere Tools, die eine stabile URL benötigen. Jeder Token ist nur für den Kamerastream und kann jederzeit widerrufen werden.',
+    loading: 'Laden…',
     confirmRevoke: {
-      title: 'Revoke this token?',
-      body: 'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
-      cancel: 'Cancel',
-      confirm: 'Revoke',
+      title: 'Dieses Token widerrufen?',
+      body: 'Jedes Gerät, das "{{name}}" verwendet, verliert sofort den Zugriff. Dies kann nicht rückgängig gemacht werden.',
+      cancel: 'Abbrechen',
+      confirm: 'Widerrufen',
     },
     create: {
-      title: 'Create new token',
-      nameLabel: 'Token name',
-      namePlaceholder: 'e.g. Home Assistant',
-      daysLabel: 'Days until expiry',
-      submit: 'Create',
+      title: 'Neues Token erstellen',
+      nameLabel: 'Token-Name',
+      namePlaceholder: 'z. B. Home Assistant',
+      daysLabel: 'Tage bis Ablauf',
+      submit: 'Erstellen',
       hint:
-        'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
+        'Maximale Lebensdauer 365 Tage. Der Token-Wert wird nur einmal bei der Erstellung angezeigt – jetzt kopieren.',
     },
     created: {
-      title: 'Token created — copy it now',
+      title: 'Token erstellt – jetzt kopieren',
       warning:
-        'This is the only time this token will be visible. After you close this dialog you can never view it again.',
-      copy: 'Copy',
-      dismiss: "I've saved it",
+        'Dies ist das einzige Mal, dass dieser Token sichtbar ist. Nach dem Schließen dieses Dialogs können Sie ihn nie wieder anzeigen.',
+      copy: 'Kopieren',
+      dismiss: 'Ich habe es gespeichert',
     },
     list: {
-      myTitle: 'My tokens',
-      allTitle: 'All users (admin view)',
-      empty: 'No tokens yet.',
+      myTitle: 'Meine Tokens',
+      allTitle: 'Alle Benutzer (Admin-Ansicht)',
+      empty: 'Noch keine Tokens.',
       name: 'Name',
-      owner: 'Owner',
-      prefix: 'Prefix',
-      created: 'Created',
-      expires: 'Expires',
-      lastUsed: 'Last used',
-      revoke: 'Revoke',
-      expired: 'Expired',
+      owner: 'Eigentümer',
+      prefix: 'Präfix',
+      created: 'Erstellt',
+      expires: 'Läuft ab',
+      lastUsed: 'Zuletzt verwendet',
+      revoke: 'Widerrufen',
+      expired: 'Abgelaufen',
     },
     toast: {
-      created: 'Token created',
-      createFailed: 'Failed to create token',
-      revoked: 'Token revoked',
-      revokeFailed: 'Failed to revoke token',
-      loadFailed: 'Failed to load tokens',
-      copied: 'Copied to clipboard',
-      copyFailed: 'Copy failed — select and copy manually',
+      created: 'Token erstellt',
+      createFailed: 'Token konnte nicht erstellt werden',
+      revoked: 'Token widerrufen',
+      revokeFailed: 'Token konnte nicht widerrufen werden',
+      loadFailed: 'Tokens konnten nicht geladen werden',
+      copied: 'In Zwischenablage kopiert',
+      copyFailed: 'Kopieren fehlgeschlagen – manuell auswählen und kopieren',
     },
   },
 
@@ -5749,7 +5749,7 @@ export default {
     skuLeadTimeHint: '0 = globale Lieferzeit verwenden. Größer 0 setzen, um für diesen SKU zu überschreiben.',
     safetyMarginLabel: 'Sicherheitspuffer',
     effectiveLeadTime: 'Effektive Lieferzeit',
-    effectiveLeadTimeHint: 'max(global {{global}}d, SKU {{sku}}d)',
+    effectiveLeadTimeHint: 'max(global {{global}}T, SKU {{sku}}T)',
     reorderPointHint: 'd̄ × LT + safety margin — bei diesem Bestand bestellen',
     safetyMarginHint: 'Statistischer Sicherheitsbestand (z=1,65 × σ × √LT) + benutzerdefinierter Puffer',
     safetyMarginHintDays: 'Zusätzlicher Puffer auf den statistischen Sicherheitsbestand.{{approx}}',

Fichier diff supprimé car celui-ci est trop grand
+ 253 - 253
frontend/src/i18n/locales/fr.ts


+ 302 - 302
frontend/src/i18n/locales/it.ts

@@ -49,7 +49,7 @@ export default {
     yes: 'Si',
     no: 'No',
     on: 'On',
-    off: 'Off',
+    off: 'Spento',
     all: 'Tutti',
     none: 'Nessuno',
     search: 'Cerca',
@@ -232,7 +232,7 @@ export default {
     resume: 'Riprendi',
     pause: 'Pausa',
     stop: 'Ferma',
-    camera: 'Camera',
+    camera: 'Telecamera',
     skipObject: 'Salta Oggetto',
     reconnect: 'Riconnetti',
     forceRefresh: 'Forza aggiornamento',
@@ -318,10 +318,10 @@ export default {
       calibrationSaved: 'Calibrazione salvata!',
       calibrationFailed: 'Calibrazione non riuscita',
       rfidRereadInitiated: 'Rilettura RFID avviata',
-      loadInitiated: 'Loading filament…',
-      unloadInitiated: 'Unloading filament…',
-      failedToLoad: 'Failed to load filament',
-      failedToUnload: 'Failed to unload filament',
+      loadInitiated: 'Caricamento filamento…',
+      unloadInitiated: 'Scaricamento filamento…',
+      failedToLoad: 'Caricamento filamento fallito',
+      failedToUnload: 'Scaricamento filamento fallito',
     },
     // Connection status
     connection: {
@@ -347,8 +347,8 @@ export default {
     },
     // AMS load/unload (#891)
     ams: {
-      load: 'Load',
-      unload: 'Unload',
+      load: 'Carica',
+      unload: 'Scarica',
     },
     bedJog: {
       title: 'Muovi il piano di stampa',
@@ -729,7 +729,7 @@ export default {
       removeF3d: 'Rimuovi F3D',
       download: 'Scarica',
       copyDownloadLink: 'Copia link download',
-      qrCode: 'QR Code',
+      qrCode: 'Codice QR',
       viewPhotos: 'Vedi foto',
       viewPhotosCount: 'Vedi foto ({{count}})',
       projectPage: 'Pagina progetto',
@@ -742,7 +742,7 @@ export default {
       removeFromProject: 'Rimuovi dal progetto',
       loading: 'Caricamento...',
       noProjectsAvailable: 'Nessun progetto disponibile',
-      searchProjects: 'Search projects…',
+      searchProjects: 'Cerca progetti…',
       select: 'Seleziona',
       deselect: 'Deseleziona',
       delete: 'Elimina',
@@ -792,7 +792,7 @@ export default {
       estimated: 'Stimato: {{time}}',
       actual: 'Reale: {{time}}',
       accuracy: 'Accuratezza: {{percent}}%',
-      filament: '{{weight}}g',
+      filament: '{{weight}} g',
       layer: '{{count}} strato',
       layers: '{{count}} strati',
       object: '{{count}} oggetto',
@@ -810,7 +810,7 @@ export default {
       openInBambuStudioToSlice: 'Apri nello slicer per slicing',
       slice: 'Slice',
       externalLink: 'Link esterno',
-      makerWorld: 'MakerWorld: {{designer}}',
+      makerWorld: 'MakerWorld {{designer}}',
       viewProject: 'Vedi progetto',
       noExternalLink: 'Nessun link esterno',
       preview3d: 'Anteprima 3D',
@@ -843,7 +843,7 @@ export default {
       deleteArchive: 'Elimina Archivio',
       deleteConfirm: 'Sei sicuro di eliminare "{{name}}"? Questa azione non può essere annullata.',
       deleteButton: 'Elimina',
-      deletePurgeStats: 'Also remove this print from Quick Stats (filament, time, cost, energy)',
+      deletePurgeStats: 'Rimuovi anche questa stampa dalle Quick Stats (filamento, tempo, costo, energia)',
       removeSource3mf: 'Rimuovi Sorgente 3MF',
       removeSource3mfConfirm: 'Sei sicuro di rimuovere il file sorgente 3MF da "{{name}}"? Questo eliminerà il progetto slicer originale.',
       removeButton: 'Rimuovi',
@@ -851,7 +851,7 @@ export default {
       removeF3dConfirm: 'Sei sicuro di rimuovere il file Fusion 360 da "{{name}}"?',
       removeTimelapse: 'Rimuovi timelapse',
       removeTimelapseConfirm: 'Sei sicuro di voler rimuovere il video timelapse da "{{name}}"?',
-      timelapse: '{{name}} - Timelapse',
+      timelapse: '{{name}}  Timelapse',
       selectTimelapse: 'Seleziona Timelapse',
       selectTimelapseDesc: 'Nessun abbinamento automatico trovato. Seleziona il timelapse per questa stampa:',
       deleteArchives: 'Elimina Archivi',
@@ -1397,7 +1397,7 @@ export default {
       general: 'Generale',
       smartPlugs: 'Prese smart',
       notifications: 'Notifiche',
-      queue: 'Workflow',
+      queue: 'Flusso',
       filament: 'Filamento',
       network: 'Rete',
       apiKeys: 'Chiavi API',
@@ -1409,7 +1409,7 @@ export default {
       ldap: 'LDAP',
       twoFa: 'Autenticazione 2FA',
       oidc: 'SSO / OIDC',
-      security: 'Security',
+      security: 'Sicurezza',
       spoolbuddy: 'SpoolBuddy',
     },
     ldap: {
@@ -1693,7 +1693,7 @@ export default {
     addFirstSmartPlug: 'Aggiungi la tua prima presa smart',
     // Notifications
     providers: 'Provider',
-    log: 'Log',
+    log: 'Registro',
     testAll: 'Testa tutto',
     testResults: 'Risultati test',
     testPassedCount: '{{count}} riusciti',
@@ -1716,14 +1716,14 @@ export default {
     manageQueueDescription: 'Aggiungi e rimuovi elementi dalla coda di stampa',
     controlPrinter: 'Controlla stampante',
     controlPrinterDescription: 'Metti in pausa, riprendi e ferma stampe',
-    cloudAccess: 'Allow cloud access',
-    cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
+    cloudAccess: 'Consenti accesso cloud',
+    cloudAccessDescription: 'Legge i preset e i filamenti Bambu Cloud per tuo conto. Richiede l\'accesso a Bambu Cloud.',
     cloudBadge: 'Cloud',
-    updateEnergyCost: 'Update electricity price',
-    updateEnergyCostDescription: 'Allow this key to POST a new per-kWh electricity price to /settings/electricity-price. Useful for Home Assistant dynamic-tariff automations (Tibber, Octopus, etc.). This is the only settings field writable via API key.',
-    energyCostBadge: 'Energy',
+    updateEnergyCost: 'Aggiorna prezzo elettricità',
+    updateEnergyCostDescription: 'Consenti a questa chiave di inviare in POST un nuovo prezzo elettricità per kWh a /settings/electricity-price. Utile per automazioni Home Assistant a tariffa dinamica (Tibber, Octopus, ecc.). Questo è l\'unico campo di impostazioni scrivibile via API key.',
+    energyCostBadge: 'Energia',
     legacyKey: 'Legacy',
-    legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
+    legacyKeyTooltip: 'Creato prima della proprietà per utente; ricreare per accesso cloud',
     unnamedKey: 'Chiave senza nome',
     lastUsed: 'Ultimo uso',
     read: 'Lettura',
@@ -1773,8 +1773,8 @@ export default {
     defaultLayerInspectDesc: 'Ispezione IA del primo strato',
     defaultTimelapse: 'Timelapse',
     defaultTimelapseDesc: 'Registra un video timelapse',
-    staggeredStart: 'Staggered Start',
-    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggeredStart: 'Avvio scaglionato',
+    staggeredStartDescription: 'Dimensione gruppo e intervallo predefiniti per scaglionare avvii di batch multi-stampante. Sovrascrivibili per batch nella finestra di stampa.',
     plateClear: 'Conferma piatto libero',
     requirePlateClear: 'Richiedi conferma piatto libero',
     requirePlateClearDescription: 'Quando questa opzione è abilitata, lo scheduler attende una conferma per stampante che il piatto sia libero prima di avviare le stampe in coda su stampanti con lavori completati. Disabilitandola vengono nascosti anche il badge di stato del piatto e il pulsante "Segna il piatto come liberato" sulle schede stampante.',
@@ -1785,10 +1785,10 @@ export default {
     gcodeEndLabel: 'G-code finale',
     gcodeStartPlaceholder: 'G-code inserito prima dell\'inizio della stampa...',
     gcodeEndPlaceholder: 'G-code aggiunto dopo la fine della stampa...',
-    staggerGroupSize: 'Group size',
-    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
-    staggerInterval: 'Interval (minutes)',
-    staggerIntervalHelp: 'Delay between each group starting',
+    staggerGroupSize: 'Dimensione gruppo',
+    staggerGroupSizeHelp: 'Stampanti da avviare simultaneamente per gruppo',
+    staggerInterval: 'Intervallo (minuti)',
+    staggerIntervalHelp: 'Ritardo tra l\'avvio di ogni gruppo',
     queueDrying: 'Asciugatura automatica',
     queueDryingDescription: 'Asciugare automaticamente il filamento AMS quando la stampante è inattiva tra le stampe in coda. Usa la soglia di umidità sopra.',
     queueDryingEnabled: 'Abilita asciugatura automatica',
@@ -1811,7 +1811,7 @@ export default {
     enableAuthentication: 'Abilita autenticazione',
     currentUser: 'Utente corrente',
     changePassword: 'Cambia password',
-    admin: 'Admin',
+    admin: 'Amministratore',
     users: 'Utenti',
     addUser: 'Aggiungi utente',
     groups: 'Gruppi',
@@ -1856,28 +1856,28 @@ export default {
     embeddedOverlay: 'Overlay incorporato',
     preferredSlicer: 'Slicer preferito',
     preferredSlicerDescription: 'Scegli quale applicazione slicer usare per aprire i file',
-    orcaslicerKnownIssuesWarning: 'OrcaSlicer 2.3.2 / 2.4.0-dev have known CLI bugs that block slicing many Bambu-authored 3MFs — see upstream issues #12426 (segfault on painted multi-extruder files) and #13386 (parameter-range strict-validation reject). Bambu Studio is recommended until the upstream fixes land.',
-    useSlicerApi: 'Use Slicer API',
-    useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
+    orcaslicerKnownIssuesWarning: 'OrcaSlicer 2.3.2 / 2.4.0-dev hanno bug CLI noti che bloccano lo slicing di molti 3MF creati da Bambu — vedi le issue upstream #12426 (segfault su file multi-estrusore dipinti) e #13386 (rifiuto della validazione rigorosa dei range di parametri). Bambu Studio è raccomandato finché non arrivano le correzioni upstream.',
+    useSlicerApi: 'Usa Slicer API',
+    useSlicerApiDescription: 'Se attivo, le azioni "Slice" aprono il modale slicer integrato e chiamano il sidecar slicer-API. Se disattivo (predefinito), inoltrano al slicer desktop tramite schema URI.',
     slicerCard: 'Slicer',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
-    slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
+    slicerApiUrlDescription: 'URL del container sidecar slicer-API. Lascia vuoto per usare le variabili d\'ambiente SLICER_API_URL / BAMBU_STUDIO_API_URL.',
     slicerBundles: {
-      title: 'Slicer Bundles',
-      description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
-      uploadButton: 'Upload bundle',
-      uploading: 'Uploading…',
-      loading: 'Loading bundles…',
-      empty: 'No bundles imported yet.',
-      summary: '{{processCount}} process · {{filamentCount}} filament presets',
-      delete: 'Delete',
-      uploadSuccess: 'Imported {{name}}',
-      uploadError: 'Bundle upload failed: {{message}}',
-      deleteSuccess: 'Bundle removed',
-      deleteError: 'Bundle delete failed: {{message}}',
-      confirmDeleteTitle: 'Remove this bundle?',
-      confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
+      title: 'Bundle slicer',
+      description: 'Importa un Printer Preset Bundle (.bbscfg) esportato da BambuStudio (File → Esporta → Esporta bundle preset → "Printer preset bundle"). Una volta importato, le richieste di slicing possono scegliere preset dal bundle per nome senza ricaricare il triplet di profili JSON.',
+      uploadButton: 'Carica bundle',
+      uploading: 'Caricamento…',
+      loading: 'Caricamento bundle…',
+      empty: 'Nessun bundle importato ancora.',
+      summary: '{{processCount}} processo · {{filamentCount}} preset filamento',
+      delete: 'Elimina',
+      uploadSuccess: '{{name}} importato',
+      uploadError: 'Caricamento bundle fallito: {{message}}',
+      deleteSuccess: 'Bundle rimosso',
+      deleteError: 'Eliminazione bundle fallita: {{message}}',
+      confirmDeleteTitle: 'Rimuovere questo bundle?',
+      confirmDeleteMessage: 'Le richieste di slicing che fanno riferimento a "{{name}}" falliranno fino al re-import del bundle.',
     },
     externalCameras: 'Camere esterne',
     costTracking: 'Tracciamento costi',
@@ -2094,9 +2094,9 @@ export default {
     cameraTypeRtsp: 'Stream RTSP',
     cameraTypeSnapshot: 'Snapshot HTTP',
     cameraTypeUsb: 'Fotocamera USB (V4L2)',
-    cameraSnapshotUrl: 'Snapshot URL (optional)',
+    cameraSnapshotUrl: 'URL istantanea (facoltativo)',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
-    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, layer-timelapse frames, and plate detection. Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
+    cameraSnapshotUrlHelp: 'URL singolo frame usato per miniature di notifica, foto di fine, frame del layer timelapse e rilevamento del piano. Timelapse e rilevamento del piano richiedono ciascuno il proprio toggle per stampante — questo URL è solo la sorgente immagine che usano quando attivi. Lascia vuoto per acquisire dallo stream live sopra. Utile per go2rtc (/api/frame.jpeg) e telecamere IP con endpoint snapshot dedicato.',
     cameraRotation: 'Rotazione',
     test: 'Test',
     connected: 'Connesso',
@@ -2251,7 +2251,7 @@ export default {
       form: {
         name: 'Nome visualizzato',
         issuerUrl: 'URL emittente',
-        clientId: 'Client ID',
+        clientId: 'ID client',
         clientSecret: 'Client secret',
         scopes: 'Scope',
         iconUrl: 'URL icona (opzionale)',
@@ -2264,8 +2264,8 @@ export default {
         secretPlaceholder: 'nuovo segreto',
         emailClaim: 'Claim email',
         emailClaimDesc: "Claim JWT usato come identità email. Usare 'preferred_username' o 'upn' per Azure Entra ID (che non invia email_verified). Usare solo nomi di claim affidabili.",
-        emailClaimPlaceholder: 'email',
-        emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
+        emailClaimPlaceholder: 'e-mail',
+        emailClaimCustomClaimAutoLinkWarning: 'I claim personalizzati sono sicuri per il collegamento automatico solo se il valore è gestito dal tenant (es. Azure Entra ID upn / preferred_username). Non abilitare il collegamento automatico se il tuo IdP consente agli utenti di auto-assegnare questo claim.',
         requireEmailVerified: 'Richiedi verifica email',
         requireEmailVerifiedDesc: "Accetta il claim email solo se il provider lo contrassegna come verificato.",
         requireEmailVerifiedWarning: 'Attenzione: l\'email sarà accettata senza verifica. Usare solo con provider affidabili.',
@@ -2278,19 +2278,19 @@ export default {
 
     // TODO: translate encryption keys
     encryption: {
-      title: 'MFA Encryption Status',
-      enabledFromEnv: 'At-rest encryption enabled (key from MFA_ENCRYPTION_KEY environment variable)',
-      enabledFromFile: 'At-rest encryption enabled (key loaded from data directory)',
-      enabledGenerated: 'At-rest encryption enabled with auto-generated key',
-      notConfigured: 'At-rest encryption not configured',
-      notConfiguredDesc: 'TOTP secrets and OIDC client_secrets are stored in plaintext. Set MFA_ENCRYPTION_KEY or restart Bambuddy with a writable data directory to auto-generate one.',
-      allEncrypted: 'All MFA secrets are encrypted at rest.',
-      legacyRowsLabel: 'Legacy plaintext rows',
-      encryptedRowsLabel: 'Encrypted rows',
-      legacyRowsWarning: '{{count}} legacy plaintext row(s) detected. Re-save the OIDC provider or re-enroll the user’s authenticator app to migrate to encrypted storage.',
-      backupHint: 'The auto-generated key is stored at DATA_DIR/.mfa_encryption_key and is included in local backup ZIPs. Keep your backups secure or set MFA_ENCRYPTION_KEY explicitly.',
-      decryptionBrokenTitle: 'Encryption key missing',
-      decryptionBrokenError: '{{count}} encrypted record(s) cannot be decrypted because the encryption key is no longer available. Restore the previous MFA_ENCRYPTION_KEY or DATA_DIR/.mfa_encryption_key to recover.',
+      title: 'Stato crittografia MFA',
+      enabledFromEnv: 'Crittografia a riposo abilitata (chiave dalla variabile d\'ambiente MFA_ENCRYPTION_KEY)',
+      enabledFromFile: 'Crittografia a riposo abilitata (chiave caricata dalla directory dei dati)',
+      enabledGenerated: 'Crittografia a riposo abilitata con chiave auto-generata',
+      notConfigured: 'Crittografia a riposo non configurata',
+      notConfiguredDesc: 'I segreti TOTP e i client_secrets OIDC sono memorizzati in chiaro. Imposta MFA_ENCRYPTION_KEY o riavvia Bambuddy con una directory dati scrivibile per generarne uno automaticamente.',
+      allEncrypted: 'Tutti i segreti MFA sono crittografati a riposo.',
+      legacyRowsLabel: 'Righe legacy in chiaro',
+      encryptedRowsLabel: 'Righe crittografate',
+      legacyRowsWarning: 'Rilevate {{count}} righe legacy in chiaro. Salva di nuovo il provider OIDC o re-iscrivi l\'app authenticator dell\'utente per migrare allo storage crittografato.',
+      backupHint: 'La chiave generata automaticamente è memorizzata in DATA_DIR/.mfa_encryption_key ed è inclusa nei backup locali ZIP. Mantieni sicuri i tuoi backup o imposta MFA_ENCRYPTION_KEY esplicitamente.',
+      decryptionBrokenTitle: 'Chiave di crittografia mancante',
+      decryptionBrokenError: '{{count}} record crittografati non possono essere decifrati perché la chiave di crittografia non è più disponibile. Ripristina il precedente MFA_ENCRYPTION_KEY o DATA_DIR/.mfa_encryption_key per recuperare.',
       migrationErrorWarning: "{{count}} riga/righe legacy non sono state ricifrate all'avvio. Controlla i log del server e riavvia Bambuddy per riprovare.",
     },
 
@@ -2368,7 +2368,7 @@ export default {
     },
     printerError: {
       title: 'Errore stampante',
-      body: '{{printer}}: {{error}}',
+      body: '{{printer}} {{error}}',
     },
     filamentLow: {
       title: 'Filamento in esaurimento',
@@ -2420,7 +2420,7 @@ export default {
     startLogging: 'Avvia logging',
     stopLogging: 'Ferma logging',
     clearLog: 'Pulisci log',
-    topic: 'Topic',
+    topic: 'Argomento',
     timestamp: 'Timestamp',
     direction: 'Direzione',
     all: 'Tutti',
@@ -2602,7 +2602,7 @@ export default {
     title: 'Vista camera',
     invalidPrinterId: 'ID stampante non valido',
     live: 'Live',
-    snapshot: 'Snapshot',
+    snapshot: 'Istantanea',
     restartStream: 'Riavvia stream',
     refreshSnapshot: 'Aggiorna snapshot',
     fullscreen: 'Schermo intero',
@@ -2620,7 +2620,7 @@ export default {
     cameraStream: 'Stream camera',
     zoomOut: 'Zoom indietro',
     zoomIn: 'Zoom avanti',
-    resetZoom: 'Reset zoom',
+    resetZoom: 'Reimposta zoom',
     recording: 'Registrazione',
     startRecording: 'Avvia registrazione',
     stopRecording: 'Ferma registrazione',
@@ -2685,7 +2685,7 @@ export default {
     backToSettings: 'Torna a Impostazioni',
     createUser: 'Crea utente',
     noPermission: 'Non hai il permesso di accedere a questa pagina.',
-    admin: 'Admin',
+    admin: 'Amministratore',
     noGroups: 'Nessun gruppo',
     active: 'Attivo',
     inactive: 'Inattivo',
@@ -2706,7 +2706,7 @@ export default {
       fillRequired: 'Compila tutti i campi obbligatori',
       passwordsDoNotMatch: 'Le password non coincidono',
       passwordTooShort: 'La password deve essere di almeno 6 caratteri',
-      ldapProvisioned: 'Provisioned LDAP user "{{username}}"',
+      ldapProvisioned: 'Utente LDAP "{{username}}" provisionato',
     },
     modal: {
       createUser: 'Crea utente',
@@ -2717,26 +2717,26 @@ export default {
       saveChanges: 'Salva modifiche',
       advancedAuthSubtitle: 'con autenticazione avanzata',
       // Manual LDAP provisioning (#1298) — English fallbacks
-      tabsAriaLabel: 'User source',
-      localTab: 'Local',
+      tabsAriaLabel: 'Origine utente',
+      localTab: 'Locale',
       ldapTab: 'LDAP',
-      ldapSearchLabel: 'Search directory',
-      ldapSearchPlaceholder: 'Type a username, name, or email...',
-      ldapMinChars: 'Type at least 2 characters to search',
-      ldapTypeToSearch: 'Start typing to search the LDAP directory',
-      ldapSearching: 'Searching directory...',
-      ldapNoResults: 'No matching users in the directory',
-      ldapSearchError: 'Directory search failed. Check the LDAP server status.',
-      ldapAlreadyProvisioned: 'Already provisioned',
-      ldapSelectedLabel: 'Selected',
-      ldapProvision: 'Provision user',
+      ldapSearchLabel: 'Cerca nella directory',
+      ldapSearchPlaceholder: 'Digita un nome utente, nome o e-mail...',
+      ldapMinChars: 'Digita almeno 2 caratteri per cercare',
+      ldapTypeToSearch: 'Inizia a digitare per cercare nella directory LDAP',
+      ldapSearching: 'Ricerca nella directory...',
+      ldapNoResults: 'Nessun utente corrispondente nella directory',
+      ldapSearchError: 'Ricerca nella directory fallita. Verifica lo stato del server LDAP.',
+      ldapAlreadyProvisioned: 'Già provisionato',
+      ldapSelectedLabel: 'Selezionato',
+      ldapProvision: 'Provisiona utente',
       ldapProvisioning: 'Provisioning...',
-      ldapErrorProvision: 'Provisioning failed. Check the LDAP server status and try again.',
+      ldapErrorProvision: 'Provisioning fallito. Verifica lo stato del server LDAP e riprova.',
     },
     form: {
       username: 'Nome utente',
       usernamePlaceholder: 'Inserisci nome utente',
-      email: 'Email',
+      email: 'E-mail',
       emailPlaceholder: 'utente@esempio.com',
       password: 'Password',
       passwordPlaceholder: 'Inserisci password',
@@ -2785,7 +2785,7 @@ export default {
     tabs: {
       cloud: 'Profili cloud',
       local: 'Profili locali',
-      kprofiles: 'K-Profiles',
+      kprofiles: 'Profili K',
     },
     localProfiles: {
       title: 'Profili locali',
@@ -2838,7 +2838,7 @@ export default {
     login: {
       title: 'Connetti a Bambu Cloud',
       subtitle: 'Sincronizza i preset del slicer tra dispositivi',
-      email: 'Email',
+      email: 'E-mail',
       password: 'Password',
       region: 'Regione',
       regionGlobal: 'Globale',
@@ -2847,7 +2847,7 @@ export default {
       totpCode: 'Codice autenticatore',
       checkEmail: 'Controlla la tua email ({{email}}) per un codice a 6 cifre',
       enterTotpHint: 'Inserisci il codice a 6 cifre dalla tua app autenticatore',
-      accessToken: 'Access Token',
+      accessToken: 'Token di accesso',
       accessTokenHint: 'Incolla il tuo access token Bambu Lab (da Bambu Studio)',
       back: 'Indietro',
       loginButton: 'Accedi',
@@ -3094,7 +3094,7 @@ export default {
     noPermissionLinkFolder: 'Non hai il permesso di collegare cartelle',
     noPermissionDeleteFolder: 'Non hai il permesso di eliminare cartelle',
     noPermissionPrint: 'Non hai il permesso di stampare',
-    noPermissionSlice: 'You do not have permission to slice files',
+    noPermissionSlice: 'Non hai il permesso di sezionare i file',
     noPermissionAddToQueue: 'Non hai il permesso di aggiungere alla coda',
     noPermissionDownload: 'Non hai il permesso di scaricare file',
     noPermissionRenameFile: 'Non hai il permesso di rinominare questo file',
@@ -3379,7 +3379,7 @@ export default {
     vendor: 'Produttore',
     material: 'Materiale',
     color: 'Colore',
-    kFactor: 'K Factor',
+    kFactor: 'Fattore K',
     temperature: 'Temperatura',
     noFilaments: 'Nessun filamento in libreria',
     deleteConfirm: 'Sei sicuro di voler eliminare questo filamento?',
@@ -3389,46 +3389,46 @@ export default {
 
   // Slice (slicer-API integration via SliceModal)
   slice: {
-    title: 'Slice model',
+    title: 'Slicing modello',
     action: 'Slice',
     slicing: 'Slicing…',
-    printer: 'Printer profile',
-    process: 'Process profile',
-    filament: 'Filament profile',
-    filamentSlot: 'Filament {{index}} ({{type}})',
-    selectPreset: '— Select a preset —',
-    loadingPresets: 'Loading presets…',
-    analyzingPlateFilaments: 'Analyzing plate filaments…',
-    analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
-    previewToast: 'Analyzing {{name}} — {{elapsed}}',
-    previewWithProgress: 'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
-    notUsedByPlate: '— not used by this plate',
-    printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
-    noPresetsForSlot: 'No presets available',
-    presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
-    allPresetsRequired: 'All presets must be selected',
-    enqueuing: 'Submitting slice job…',
-    queued: 'Queued…',
-    failed: 'Slicing failed. Check the slicer sidecar logs.',
-    startedToast: 'Slicing {{name}} in the background…',
-    queuedToast: 'Queued: {{name}} — {{elapsed}}',
-    runningToast: 'Slicing {{name}}  {{elapsed}}',
-    runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
-    completedToast: 'Sliced {{name}}',
-    failedToast: 'Slicing {{name}} failed: {{detail}}',
+    printer: 'Profilo stampante',
+    process: 'Profilo processo',
+    filament: 'Profilo filamento',
+    filamentSlot: 'Filamento {{index}} ({{type}})',
+    selectPreset: '— Seleziona un preset —',
+    loadingPresets: 'Caricamento preset…',
+    analyzingPlateFilaments: 'Analisi filamenti del piano…',
+    analyzingPlateFilamentsHint: 'Slicing di anteprima per scoprire quali slot AMS usa questo piano. In cache dopo — la riapertura è istantanea.',
+    previewToast: 'Analisi di {{name}} – {{elapsed}}',
+    previewWithProgress: 'Analisi di {{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
+    notUsedByPlate: '— non usato da questo piano',
+    printerMismatch: 'Questo 3MF è stato sezionato per {{source}}, ma hai scelto {{target}}. La CLI del slicer non può ri-sezionare un 3MF per una stampante diversa — apri la sorgente in Bambu Studio, cambia stampante e ri-esporta.',
+    noPresetsForSlot: 'Nessun preset disponibile',
+    presetsLoadFailed: 'Caricamento preset fallito. Apri Impostazioni → Profili per importarli prima.',
+    allPresetsRequired: 'Tutti i preset devono essere selezionati',
+    enqueuing: 'Invio lavoro di slicing…',
+    queued: 'In coda…',
+    failed: 'Slicing fallito. Controlla i log del sidecar.',
+    startedToast: 'Slicing di {{name}} in background…',
+    queuedToast: 'In coda: {{name}} – {{elapsed}}',
+    runningToast: 'Slicing {{name}}  {{elapsed}}',
+    runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
+    completedToast: '{{name}} sezionato',
+    failedToast: 'Slicing di {{name}} fallito: {{detail}}',
     tier: {
-      local: 'Imported',
+      local: 'Importato',
       cloud: 'Cloud',
       standard: 'Standard',
     },
     cloud: {
-      notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
-      expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
-      unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
+      notAuthenticated: 'Accedi a Bambu Cloud (Impostazioni → Profili → Cloud) per vedere i preset cloud.',
+      expired: 'Sessione Bambu Cloud scaduta – accedi di nuovo per aggiornare i preset cloud.',
+      unreachable: 'Bambu Cloud non è raggiungibile. I preset locali e standard funzionano ancora.',
     },
     bedType: {
-      label: 'Build plate',
-      auto: 'Auto (use process preset)',
+      label: 'Piano di stampa',
+      auto: 'Auto (usa preset di processo)',
       coolPlate: 'Cool Plate',
       coolPlateSuperTack: 'Cool Plate SuperTack',
       engineering: 'Engineering Plate',
@@ -3481,26 +3481,26 @@ export default {
     spoolmanMixedContentFixOpenNewTab: 'Come alternativa, apri Spoolman in una nuova scheda via HTTP — le regole sul contenuto misto si applicano solo ai frame incorporati, una scheda autonoma funziona.',
     spoolmanOpenInNewTab: 'Apri Spoolman in una nuova scheda',
     labels: {
-      title: 'Print spool labels',
-      selectedCount: '{{count}} selected',
-      pickSpools: 'Pick which spools to print labels for:',
-      searchPlaceholder: 'Search name, brand, or #ID',
-      filterByMaterial: 'Material:',
-      allMaterials: 'All',
-      selectVisible: 'Select all visible ({{count}})',
-      deselectVisible: 'Deselect visible',
-      clearAll: 'Clear all',
-      noSpoolsToShow: 'No spools to show. Adjust your filter and try again.',
-      noMatches: 'No spools match the current search or filter.',
-      printOne: 'Print label for this spool',
-      printLabels: 'Print labels…',
-      bulkTitle: 'Pick spools to print labels for from the {{count}} currently shown',
-      noSpoolsTitle: 'No spools to label',
-      error: 'Could not generate labels: {{msg}}',
+      title: 'Stampa etichette bobine',
+      selectedCount: '{{count}} selezionato',
+      pickSpools: 'Scegli per quali bobine stampare le etichette:',
+      searchPlaceholder: 'Cerca per nome, marca o #ID',
+      filterByMaterial: 'Materiale:',
+      allMaterials: 'Tutto',
+      selectVisible: 'Seleziona tutti visibili ({{count}})',
+      deselectVisible: 'Deseleziona visibili',
+      clearAll: 'Cancella tutto',
+      noSpoolsToShow: 'Nessuna bobina da mostrare. Modifica il filtro e riprova.',
+      noMatches: 'Nessuna bobina corrisponde alla ricerca o al filtro corrente.',
+      printOne: 'Stampa etichetta per questa bobina',
+      printLabels: 'Stampa etichette…',
+      bulkTitle: 'Scegli le bobine da etichettare tra le {{count}} mostrate',
+      noSpoolsTitle: 'Nessuna bobina da etichettare',
+      error: 'Impossibile generare etichette: {{msg}}',
       templates: {
         ams: {
           label: 'AMS holder (30 × 15 mm)',
-          hint: 'Single label per page; fits the popular AMS filament label holder.',
+          hint: 'Una etichetta per pagina; adatta al popolare portaetichette AMS.',
         },
         box40x30: {
           label: 'Etichetta scatola (40 × 30 mm)',
@@ -3508,15 +3508,15 @@ export default {
         },
         box: {
           label: 'Box label (62 × 29 mm)',
-          hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+          hint: 'Una etichetta per pagina; dimensionata per Brother PT/QL e piccole etichette Dymo.',
         },
         averyL7160: {
           label: 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
-          hint: 'EU sheet stock; 21 labels per A4 page.',
+          hint: 'Foglio formato UE; 21 etichette per pagina A4.',
         },
         avery5160: {
           label: 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
-          hint: 'US sheet stock; 30 labels per Letter page.',
+          hint: 'Foglio formato US; 30 etichette per pagina Letter.',
         },
       },
     },
@@ -3531,7 +3531,7 @@ export default {
     useCustomBrand: 'Usa "{{brand}}"',
     useCustomMaterial: 'Usa materiale personalizzato: {{material}}',
     colorName: 'Nome Colore',
-    colorNamePlaceholder: 'Jade White, Fire Red...',
+    colorNamePlaceholder: 'Bianco Giada, Rosso Fuoco...',
     color: 'Colore',
     hexColor: 'Colore Hex',
     pickColor: 'Scegli colore personalizzato',
@@ -3568,7 +3568,7 @@ export default {
     restore: 'Ripristina',
     noSpools: 'Ancora nessuna bobina. Aggiungi la tua prima bobina per iniziare.',
     noAvailableSpools: 'Nessuna bobina disponibile. Aggiungi una bobina al tuo inventario o rimuovine l\'assegnazione da un altro slot.',
-    kProfiles: 'K-Profiles',
+    kProfiles: 'Profili K',
     addKProfile: 'Aggiungi K-Profile',
     assignSpool: 'Assegna Bobina',
     unassignSpool: 'Scollega',
@@ -3632,27 +3632,27 @@ export default {
     noColorsFound: 'Nessun colore corrisponde alla ricerca',
     noResults: 'Nessun risultato trovato',
     // Multi-color gradient + visual effect (#1154) — English fallback.
-    extraColorsLabel: 'Extra colors',
+    extraColorsLabel: 'Colori extra',
     extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
-    extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
-    extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
-    colorEffectLabel: 'Effect',
+    extraColorsHint: 'Incolla da 2 a 8 stop hex, separati da virgole. Visualizzato come sfumatura.',
+    extraColorsInvalid: 'Hex non validi ignorati: {{tokens}}',
+    colorEffectLabel: 'Effetto',
     colorEffect: {
-      none: 'None',
-      sparkle: 'Sparkle',
-      wood: 'Wood',
-      marble: 'Marble',
-      glow: 'Glow',
-      matte: 'Matte',
+      none: 'Nessuno',
+      sparkle: 'Scintillante',
+      wood: 'Legno',
+      marble: 'Marmo',
+      glow: 'Bagliore',
+      matte: 'Opaco',
       silk: 'Silk',
-      galaxy: 'Galaxy',
-      rainbow: 'Rainbow',
-      metal: 'Metal',
-      translucent: 'Translucent',
-      gradient: 'Gradient',
-      dualColor: 'Dual Color',
-      triColor: 'Tri Color',
-      multicolor: 'Multicolor',
+      galaxy: 'Galassia',
+      rainbow: 'Arcobaleno',
+      metal: 'Metallo',
+      translucent: 'Traslucido',
+      gradient: 'Sfumatura',
+      dualColor: 'Bicolore',
+      triColor: 'Tricolore',
+      multicolor: 'Multicolore',
     },
     // PA Profiles
     selectMaterialFirst: 'Selezionare prima un materiale nella scheda Info filamento.',
@@ -3779,7 +3779,7 @@ export default {
     configureSlot: 'Configura slot',
     externalSpool: 'Bobina esterna',
     profile: 'Profilo',
-    kFactor: 'K Factor',
+    kFactor: 'Fattore K',
     fill: 'Livello',
     configure: 'Configura',
     used: 'utilizzato',
@@ -3823,19 +3823,19 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}}: necessita di {{required}}g, rimanenti {{remaining}}g',
     printAnyway: 'Stampa comunque',
     forceColorMatch: 'Forza corrispondenza colore',
-    staggerPrinterStarts: 'Stagger printer starts',
-    staggerGroupSize: 'Group size',
-    staggerInterval: 'Interval (min)',
-    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
-    staggerLastGroup: 'last group: {{count}}',
-    staggerTotal: 'total: {{minutes}} min',
+    staggerPrinterStarts: 'Scaglionare avvii',
+    staggerGroupSize: 'Dimensione gruppo',
+    staggerInterval: 'Intervallo (min)',
+    staggerPreview: '{{printers}} stampanti → {{groups}} gruppi di {{size}}, avvio ogni {{interval}} min',
+    staggerLastGroup: 'ultimo gruppo: {{count}}',
+    staggerTotal: 'totale: {{minutes}} min',
     staggerToPrinters: 'Scagliona a {{count}} stampanti',
     gcodeInjection: 'Inietta G-code auto-stampa',
   },
 
   // Backup
   backup: {
-    includesEncryptionKey: 'Local backups include the MFA encryption key file (DATA_DIR/.mfa_encryption_key) so a backup ZIP is self-contained. Treat the ZIP as sensitive — anyone with the file can decrypt the OIDC client secrets and TOTP secrets stored inside.',
+    includesEncryptionKey: 'I backup locali includono il file della chiave di crittografia MFA (DATA_DIR/.mfa_encryption_key), quindi un backup ZIP è autonomo. Tratta lo ZIP come sensibile — chiunque abbia il file può decifrare i client secret OIDC e i segreti TOTP contenuti.',
     title: 'Backup e ripristino',
     createBackup: 'Crea backup',
     restoreBackup: 'Ripristina backup',
@@ -3873,7 +3873,7 @@ export default {
     enterNewToken: 'Inserisci un nuovo token per aggiornare',
     tokenHint: 'Token a grana fine con permesso di lettura/scrittura dei contenuti',
     branch: 'Branch',
-    provider: 'Git Provider',
+    provider: 'Provider Git',
     providerGitHub: 'GitHub',
 	providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
@@ -3993,27 +3993,27 @@ export default {
     close: 'Chiudi',
 
     // Scheduled local backups (#884)
-    scheduledBackup: 'Scheduled Backups',
-    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',
-    frequency: 'Frequency',
-    backupTime: 'Time',
-    retention: 'Retention',
-    retentionDescription: 'Number of backups to keep',
-    outputPath: 'Output Path',
-    outputPathPlaceholder: 'Default: {{path}}',
-    outputPathDescription: 'Leave empty for default location',
-    runNow: 'Run Now',
-    backupFiles: 'Backup Files',
-    noScheduledBackups: 'No backups yet',
-    deleteBackup: 'Delete',
-    deleteBackupConfirm: 'Delete this backup file?',
-    backupRunning: 'Backup in progress...',
-    scheduledBackupComplete: 'Backup completed successfully',
-    scheduledBackupFailed: 'Backup failed',
-    nextBackup: 'Next backup',
-    backupSize: 'Size',
+    scheduledBackup: 'Backup pianificati',
+    scheduledBackupDescription: 'Crea automaticamente snapshot di backup secondo una pianificazione. La directory di output può essere montata su un NAS o storage esterno.',
+    frequency: 'Frequenza',
+    backupTime: 'Tempo',
+    retention: 'Conservazione',
+    retentionDescription: 'Numero di backup da conservare',
+    outputPath: 'Percorso di output',
+    outputPathPlaceholder: 'Predefinito: {{path}}',
+    outputPathDescription: 'Lasciare vuoto per la posizione predefinita',
+    runNow: 'Esegui ora',
+    backupFiles: 'File di backup',
+    noScheduledBackups: 'Nessun backup ancora',
+    deleteBackup: 'Elimina',
+    deleteBackupConfirm: 'Eliminare questo file di backup?',
+    backupRunning: 'Backup in corso...',
+    scheduledBackupComplete: 'Backup completato con successo',
+    scheduledBackupFailed: 'Backup fallito',
+    nextBackup: 'Prossimo backup',
+    backupSize: 'Dimensione',
     utc: 'UTC',
-    defaultPathLabel: 'Default:',
+    defaultPathLabel: 'Predefinito:',
 
     // Category labels
     categories: {
@@ -4096,8 +4096,8 @@ export default {
       layerShift: 'Spostamento layer',
       cloggedNozzle: 'Ugello intasato',
       filamentRunout: 'Filamento esaurito',
-      warping: 'Warping',
-      stringing: 'Stringing',
+      warping: 'Deformazione',
+      stringing: 'Filamento',
       underExtrusion: 'Sotto-estrusione',
       powerFailure: 'Mancanza corrente',
       userCancelled: 'Annullato dall\'utente',
@@ -4114,7 +4114,7 @@ export default {
 
   // K-Profiles
   kProfiles: {
-    title: 'K-Profiles',
+    title: 'Profili K',
     noPrintersConfigured: 'Nessuna stampante configurata',
     addPrinterInSettings: 'Aggiungi una stampante in Impostazioni per gestire i K-profiles',
     noActivePrinters: 'Nessuna stampante attiva',
@@ -4157,14 +4157,14 @@ export default {
       editTitle: 'Modifica K-Profile',
       profileName: 'Nome profilo',
       profileNamePlaceholder: 'Il mio profilo PLA',
-      kValue: 'K-Value',
+      kValue: 'Valore K',
       kValuePlaceholder: '0.020',
       kValueHelp: 'Intervallo tipico: 0.01 - 0.06 per PLA, 0.02 - 0.10 per PETG',
       filament: 'Filamento',
       selectFilament: 'Seleziona filamento...',
       noFilamentsHelp: 'Nessun filamento trovato. Crea prima un K-profile in Bambu Studio.',
       flowType: 'Tipo flow',
-      highFlow: 'High Flow',
+      highFlow: 'Alto flusso',
       standard: 'Standard',
       nozzleSize: 'Dimensione ugello',
       extruder: 'Estrusore',
@@ -4277,12 +4277,12 @@ export default {
       description: 'Avvia automaticamente le stampe aggiunte alla coda. Se disattivato, le stampe attendono l\'avvio manuale.',
     },
     queueForceColorMatch: {
-      title: 'Force color match',
-      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
+      title: 'Forza corrispondenza colori',
+      description: 'Rifiuta di inviare a una stampante che non ha esattamente il tipo di filamento e il colore caricato. Disattivato per impostazione predefinita — senza questo, la coda usa solo la corrispondenza per modello e potrebbe scegliere una stampante con il colore sbagliato.',
     },
     tailscaleDisabled: {
       title: 'Integrazione Tailscale',
-      description: 'Enable to mark this VP as exposed over Tailscale. Shows the host\'s Tailscale address so you know which IP to paste into the slicer. The CA-import step is unchanged — this toggle has no effect on certificates.',
+      description: 'Abilita per contrassegnare questo VP come esposto tramite Tailscale. Mostra l\'indirizzo Tailscale dell\'host così sai quale IP incollare nello slicer. Il passo di importazione CA è invariato — questo toggle non ha effetto sui certificati.',
     },
     setupRequired: {
       title: 'Configurazione necessaria',
@@ -4433,10 +4433,10 @@ export default {
     linkedTo: 'Collegato a:',
     monitorOnly: 'Solo monitoraggio',
     alerts: 'Avvisi',
-    scheduleOn: 'On {{time}}',
-    scheduleOff: 'Off {{time}}',
+    scheduleOn: 'Alle {{time}}',
+    scheduleOff: 'Spento {{time}}',
     on: 'On',
-    off: 'Off',
+    off: 'Spento',
     power: 'Potenza',
     kwhToday: 'kWh Oggi',
     settings: 'Impostazioni',
@@ -4453,7 +4453,7 @@ export default {
     autoOffPersistentDescription: 'Resta attivo tra le stampe invece di una tantum',
     turnOffDelayMode: 'Modalità ritardo spegnimento',
     time: 'Tempo',
-    temp: 'Temp',
+    temp: 'Temp.',
     delayMinutes: 'Ritardo (minuti)',
     tempThreshold: 'Soglia temperatura (°C)',
     tempThresholdDescription: 'Si spegne quando l\'ugello si raffredda sotto questa temperatura',
@@ -4525,7 +4525,7 @@ export default {
     energyMonitoring: 'Monitoraggio energia',
     stateMonitoring: 'Monitoraggio stato',
     optional: 'opzionale',
-    topic: 'Topic',
+    topic: 'Argomento',
     jsonPath: 'Percorso JSON',
     multiplier: 'Moltiplicatore',
     onValue: 'Valore ON',
@@ -4533,32 +4533,32 @@ export default {
     mqttEnergyHint: 'Il percorso JSON estrae il valore dal payload JSON. Lascia vuoto per valori grezzi.\nUsa moltiplicatore 0.001 per Wh→kWh, 1000 per MWh→kWh.',
     mqttStateHint: 'Il percorso JSON estrae il valore dal payload JSON. Lascia vuoto per valori grezzi.\nValore ON: la stringa esatta che significa "ON". Lascia vuoto per rilevamento auto (ON, true, 1).',
     // REST smart plug
-    restControl: 'Control',
-    restOnUrl: 'Turn ON URL',
-    restOffUrl: 'Turn OFF URL',
-    restOnBody: 'ON Request Body',
-    restOffBody: 'OFF Request Body',
-    restMethod: 'HTTP Method',
+    restControl: 'Controllo',
+    restOnUrl: 'URL accensione',
+    restOffUrl: 'URL spegnimento',
+    restOnBody: 'Corpo richiesta ON',
+    restOffBody: 'Corpo richiesta OFF',
+    restMethod: 'Metodo HTTP',
     restHeaders: 'Custom Headers (JSON)',
-    restStatusUrl: 'Status URL',
-    restStatusPath: 'State JSON Path',
-    restStatusOnValue: 'ON Value',
+    restStatusUrl: 'URL stato',
+    restStatusPath: 'Percorso JSON stato',
+    restStatusOnValue: 'Valore ON',
     restPowerUrl: 'URL potenza',
-    restPowerPath: 'Power JSON Path',
+    restPowerPath: 'Percorso JSON potenza',
     restPowerMultiplier: 'Moltiplicatore potenza',
     restEnergyUrl: 'URL energia',
-    restEnergyPath: 'Energy JSON Path',
+    restEnergyPath: 'Percorso JSON energia',
     restEnergyMultiplier: 'Moltiplicatore energia',
-    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
-    restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
-    restBodyHint: 'e.g. ON, {"state": "on"}',
-    restStatusHint: 'URL to poll for current state',
-    restPathHint: 'e.g. state or data.power.status',
+    restUrlRequired: 'È richiesto almeno un URL (ON o OFF) per le prese REST',
+    restHeadersHint: 'es. {"Authorization": "Bearer your-token"}',
+    restBodyHint: 'es. ON, {"state": "on"}',
+    restStatusHint: 'URL per recuperare lo stato attuale',
+    restPathHint: 'es. state o data.power.status',
     restPowerUrlHint: 'URL separato per i dati di potenza (usa l\'URL di stato se vuoto)',
     restEnergyUrlHint: 'URL separato per i dati di energia (usa l\'URL di stato se vuoto)',
     restEnergyHint: 'Ogni valore può usare il proprio URL o ricadere sull\'URL di stato. Usa i moltiplicatori per la conversione delle unità (es. 0.001 per convertire Wh in kWh).',
-    testConnection: 'Test Connection',
-    connectionSuccess: 'Connection successful',
+    testConnection: 'Testa connessione',
+    connectionSuccess: 'Connessione riuscita',
     noSwitchesInSwitchbar: 'Nessun interruttore nella barra',
     enableSwitchbarHint: 'Abilita "Mostra nella barra interruttori" in Impostazioni > Smart Plugs',
   },
@@ -4571,7 +4571,7 @@ export default {
       ntfy: 'ntfy',
       pushover: 'Pushover',
       telegram: 'Telegram',
-      email: 'Email',
+      email: 'E-mail',
       discord: 'Discord',
       webhook: 'Webhook',
       homeassistant: 'Home Assistant',
@@ -4706,7 +4706,7 @@ export default {
     eventPriority: {
       sectionTitle: 'Priorità ntfy',
       helpNtfy: 'Scegli una priorità per ogni evento abilitato. ntfy le usa per intensificare gli avvisi (suono, visibilità, comportamento push). I livelli non impostati qui usano l\'impostazione predefinita del server ntfy.',
-      min: 'Min',
+      min: 'Min.',
       low: 'Bassa',
       default: 'Predefinita',
       high: 'Alta',
@@ -4995,7 +4995,7 @@ export default {
     title: 'Configura Slot AMS',
     slotConfigured: 'Slot configurato!',
     configuringSlot: 'Configurazione slot:',
-    slotLabel: '{{ams}} Slot {{slot}}',
+    slotLabel: '{{ams}} Slot {{slot}}',
     searchPresets: 'Cerca preset...',
     colorPlaceholder: 'Nome colore o hex (es. marrone, FF8800)',
     clearCustomColor: 'Cancella colore personalizzato',
@@ -5017,7 +5017,7 @@ export default {
     showLessColors: 'Mostra meno colori',
     showMoreColors: 'Mostra più colori',
     clear: 'Cancella',
-    hexLabel: 'Hex: #{{hex}}',
+    hexLabel: 'Hex #{{hex}}',
     resetting: 'Ripristino...',
     resetSlot: 'Ripristina slot',
     cancel: 'Annulla',
@@ -5037,7 +5037,7 @@ export default {
   // Email Settings
   emailSettings: {
     placeholders: {
-      fromName: 'BamBuddy',
+      fromName: 'Bambuddy',
     },
   },
 
@@ -5210,7 +5210,7 @@ export default {
       noUntagged: 'Nessuna bobina senza tag trovata',
       tagDetected: 'Tag rilevato',
       noTag: 'Nessun tag',
-      tagId: 'Tag',
+      tagId: 'Etichetta',
       grossWeight: 'Peso lordo',
       spoolSize: 'Dimensione bobina',
       close: 'Chiudi',
@@ -5287,7 +5287,7 @@ export default {
       nfcReader: 'Lettore NFC',
       type: 'Tipo',
       connection: 'Connessione',
-      notConnected: 'N/A',
+      notConnected: 'N/D',
       deviceInfo: 'Info dispositivo',
       hostname: 'Host',
       uptime: 'Tempo di attività',
@@ -5404,7 +5404,7 @@ export default {
     email: 'Email (opzionale)',
     emailPlaceholder: 'tua@email.it',
     emailPrivacy: 'Se fornita, la tua email sarà inclusa in una sezione compressa dell\'issue GitHub per permettere al manutentore di contattarti.',
-    screenshot: 'Screenshot',
+    screenshot: 'Schermata',
     uploadOrPaste: 'Carica, incolla o trascina un\'immagine',
     dataCollectedSummary: 'Quali dati sono inclusi nel report?',
     dataIncluded: 'Inclusi:',
@@ -5446,8 +5446,8 @@ export default {
     actionPauseOff: 'Pausa e stacca corrente',
     pollInterval: 'Intervallo di controllo (secondi)',
     pollIntervalHint: 'Frequenza di controllo di ogni stampante durante la stampa. Minimo 5s, massimo 120s.',
-    externalUrlMissing: 'External URL is not set.',
-    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',
+    externalUrlMissing: 'URL esterno non impostato.',
+    externalUrlHint: 'L\'API ML recupera l\'istantanea della telecamera tramite URL. Imposta l\'URL esterno nelle impostazioni generali in modo che il container API ML possa raggiungere Bambuddy.',
     perPrinterTitle: 'Stampanti monitorate',
     perPrinterHint: 'Scegli quali stampanti il servizio di rilevamento deve monitorare.',
     monitorAll: 'Monitora tutte le stampanti connesse',
@@ -5480,8 +5480,8 @@ export default {
     plateDefaultName: 'Piatto {{n}}',
     materialCount: '{{count}} filamenti',
     amsRequired: 'AMS richiesto',
-    slicedFor: 'Sliced for {{printer}}',
-    alsoCompatible: 'Also marked compatible: {{printers}}',
+    slicedFor: 'Sezionato per {{printer}}',
+    alsoCompatible: 'Anche contrassegnato come compatibile: {{printers}}',
     importToLibrary: 'Salva',
     sliceIn: 'Salva e affetta in {{slicer}}',
     disclaimer: 'L\'integrazione MakerWorld utilizza endpoint API documentati dalla community. Bambuddy non è affiliato né approvato da MakerWorld o Bambu Lab.',
@@ -5576,11 +5576,11 @@ export default {
     ageLabel: 'Sposta i file più vecchi di',
     days: 'giorni',
     includeNeverPrinted: 'Includi i file mai stampati',
-    effectsTitle: 'What happens when you click Purge',
-    effect1: 'Matching files are moved to Trash — they are not deleted from disk yet.',
-    effect2: 'You can restore them from Trash at any time until the retention window expires.',
-    effect3: 'After retention, the trash sweeper permanently removes them from disk.',
-    effect4: 'Files in external (linked) folders are skipped — Bambuddy never deletes bytes it does not own.',    previewLoading: 'Verifica quanti file corrispondono…',
+    effectsTitle: 'Cosa succede quando fai clic su Pulisci',
+    effect1: 'I file corrispondenti vengono spostati nel cestino — non sono ancora eliminati dal disco.',
+    effect2: 'Puoi ripristinarli dal cestino in qualsiasi momento fino alla scadenza del periodo di conservazione.',
+    effect3: 'Dopo il periodo di conservazione, lo spazzino del cestino li rimuove definitivamente.',
+    effect4: 'I file in cartelle esterne (collegate) vengono saltati — Bambuddy non elimina mai dati che non possiede.',    previewLoading: 'Verifica quanti file corrispondono…',
     previewFailed: 'Impossibile mostrare l\'anteprima.',
     previewSummary: '{{count}} file · {{size}} verrebbero spostati nel cestino',
     andMore: '…e altri {{count}}',
@@ -5602,87 +5602,87 @@ export default {
     saveFailed: 'Impossibile salvare le impostazioni di eliminazione automatica.',
   },
   archivePurge: {
-    headerButton: 'Purge old',
-    headerTooltip: 'Bulk-delete old archives',
-    title: 'Purge old archives',
-    description: 'Clear out old print history. Each archive is aged by its most recent print completion — reprinting an archive refreshes its age, so active work is never purged.',
-    ageLabel: 'Delete archives not printed in the last',
-    days: 'days',
-    effectsTitle: 'What happens when you click Purge',
-    effect1: 'Each matching archive is permanently removed from the database.',
-    effect2: 'The 3MF, thumbnail, timelapse, source 3MF, F3D design file, and photo folder are all deleted from disk.',
-    effect3: 'There is no trash bin for archives — deletion is immediate and cannot be undone.',
-    effect4: 'Reprinting an archive refreshes its age clock, so archives you still use are safe.',
-    previewLoading: 'Checking how many archives match…',
-    previewFailed: 'Could not preview the purge.',
-    previewSummary: '{{count}} archives · {{size}} would be deleted',
-    andMore: '…and {{count}} more',
-    warning: 'This is permanent. Download or favourite anything you want to keep before continuing.',
-    confirmCta: 'Delete {{count}} archive(s)',
-    purging: 'Deleting…',
+    headerButton: 'Elimina vecchi',
+    headerTooltip: 'Eliminazione massiva vecchi archivi',
+    title: 'Elimina vecchi archivi',
+    description: 'Cancella la vecchia cronologia di stampa. Ogni archivio invecchia in base alla sua ultima stampa completata — ristampare azzera l\'età, quindi il lavoro attivo non viene mai eliminato.',
+    ageLabel: 'Elimina archivi non stampati negli ultimi',
+    days: 'giorni',
+    effectsTitle: 'Cosa succede quando fai clic su Pulisci',
+    effect1: 'Ogni archivio corrispondente viene rimosso permanentemente dal database.',
+    effect2: 'Il 3MF, la miniatura, il timelapse, il 3MF sorgente, il file di progettazione F3D e la cartella foto vengono tutti eliminati dal disco.',
+    effect3: 'Non c\'è un cestino per gli archivi — l\'eliminazione è immediata e non può essere annullata.',
+    effect4: 'Ristampare un archivio azzera il suo conteggio dell\'età, quindi gli archivi attivi sono al sicuro.',
+    previewLoading: 'Verifica del numero di archivi corrispondenti…',
+    previewFailed: 'Impossibile visualizzare l\'anteprima della pulizia.',
+    previewSummary: '{{count}} archivi · {{size}} verrebbero eliminati',
+    andMore: '…e altri {{count}}',
+    warning: 'Questo è permanente. Scarica o aggiungi ai preferiti tutto ciò che vuoi conservare prima di continuare.',
+    confirmCta: 'Elimina {{count}} archivio(i)',
+    purging: 'Eliminazione…',
     toast: {
-      success: 'Deleted {{count}} archive(s).',
-      failed: 'Could not purge archives.',
+      success: '{{count}} archivio(i) eliminato.',
+      failed: 'Impossibile eliminare gli archivi.',
     },
   },
   archiveAutoPurge: {
-    enableLabel: 'Auto-purge old archives',
-    enableDescription: 'Once per day, permanently deletes archives that have not been printed within the threshold. Reprinting an archive resets the clock. No trash bin — deletion is immediate.',
-    ageLabel: 'Auto-delete archives not printed in the last',
-    ageDescription: 'Minimum 7 days, maximum 10 years. Based on the most recent print completion — reprinting an archive refreshes its age. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
-    days: 'days',
-    runNow: 'Purge archives now',
-    saveFailed: 'Could not save auto-purge settings.',
+    enableLabel: 'Elimina auto. vecchi archivi',
+    enableDescription: 'Una volta al giorno, elimina permanentemente gli archivi non stampati entro la soglia. Ristampare azzera il timer. Nessun cestino — eliminazione immediata.',
+    ageLabel: 'Eliminazione auto. di archivi non stampati negli ultimi',
+    ageDescription: 'Minimo 7 giorni, massimo 10 anni. Basato sull\'ultima stampa completata — ristampare azzera l\'età. Elimina archivio, 3MF, miniatura, timelapse e foto.',
+    days: 'giorni',
+    runNow: 'Elimina archivi ora',
+    saveFailed: 'Impossibile salvare le impostazioni di pulizia automatica.',
   },
   cameraTokens: {
-    title: 'Camera API Tokens',
-    navTitle: 'Camera API tokens',
+    title: 'Token API telecamera',
+    navTitle: 'Token API telecamera',
     description:
-      'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.',
-    loading: 'Loading…',
+      'Token a lunga durata per incorporare lo stream della telecamera in Home Assistant, Frigate, chioschi o qualsiasi altro strumento che richieda un URL stabile. Ogni token è limitato allo stream della telecamera e può essere revocato in qualsiasi momento.',
+    loading: 'Caricamento…',
     confirmRevoke: {
-      title: 'Revoke this token?',
-      body: 'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
-      cancel: 'Cancel',
-      confirm: 'Revoke',
+      title: 'Revocare questo token?',
+      body: 'Qualsiasi dispositivo che usa "{{name}}" perderà l\'accesso immediatamente. Non può essere annullato.',
+      cancel: 'Annulla',
+      confirm: 'Revoca',
     },
     create: {
-      title: 'Create new token',
-      nameLabel: 'Token name',
-      namePlaceholder: 'e.g. Home Assistant',
-      daysLabel: 'Days until expiry',
-      submit: 'Create',
+      title: 'Crea nuovo token',
+      nameLabel: 'Nome token',
+      namePlaceholder: 'es. Home Assistant',
+      daysLabel: 'Giorni alla scadenza',
+      submit: 'Crea',
       hint:
-        'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
+        'Durata massima 365 giorni. Il valore del token viene mostrato solo alla creazione — copialo ora.',
     },
     created: {
-      title: 'Token created — copy it now',
+      title: 'Token creato – copialo ora',
       warning:
-        'This is the only time this token will be visible. After you close this dialog you can never view it again.',
-      copy: 'Copy',
-      dismiss: "I've saved it",
+        'Questa è l\'unica volta in cui questo token sarà visibile. Dopo la chiusura di questa finestra non potrai più visualizzarlo.',
+      copy: 'Copia',
+      dismiss: 'L\'ho salvato',
     },
     list: {
-      myTitle: 'My tokens',
-      allTitle: 'All users (admin view)',
-      empty: 'No tokens yet.',
-      name: 'Name',
-      owner: 'Owner',
-      prefix: 'Prefix',
-      created: 'Created',
-      expires: 'Expires',
-      lastUsed: 'Last used',
-      revoke: 'Revoke',
-      expired: 'Expired',
+      myTitle: 'I miei token',
+      allTitle: 'Tutti gli utenti (vista admin)',
+      empty: 'Nessun token ancora.',
+      name: 'Nome',
+      owner: 'Proprietario',
+      prefix: 'Prefisso',
+      created: 'Creato',
+      expires: 'Scade',
+      lastUsed: 'Ultimo uso',
+      revoke: 'Revoca',
+      expired: 'Scaduto',
     },
     toast: {
-      created: 'Token created',
-      createFailed: 'Failed to create token',
-      revoked: 'Token revoked',
-      revokeFailed: 'Failed to revoke token',
-      loadFailed: 'Failed to load tokens',
-      copied: 'Copied to clipboard',
-      copyFailed: 'Copy failed — select and copy manually',
+      created: 'Token creato',
+      createFailed: 'Creazione token fallita',
+      revoked: 'Token revocato',
+      revokeFailed: 'Revoca token fallita',
+      loadFailed: 'Caricamento token fallito',
+      copied: 'Copiato negli appunti',
+      copyFailed: 'Copia non riuscita – seleziona e copia manualmente',
     },
   },
 

+ 288 - 288
frontend/src/i18n/locales/ja.ts

@@ -317,10 +317,10 @@ export default {
       calibrationSaved: 'キャリブレーションを保存しました!',
       calibrationFailed: 'キャリブレーションに失敗しました',
       rfidRereadInitiated: 'RFID再読み取りを開始しました',
-      loadInitiated: 'Loading filament…',
-      unloadInitiated: 'Unloading filament…',
-      failedToLoad: 'Failed to load filament',
-      failedToUnload: 'Failed to unload filament',
+      loadInitiated: 'フィラメントをロード中…',
+      unloadInitiated: 'フィラメントをアンロード中…',
+      failedToLoad: 'フィラメントのロードに失敗',
+      failedToUnload: 'フィラメントのアンロードに失敗',
     },
     // Connection status
     connection: {
@@ -346,8 +346,8 @@ export default {
     },
     // AMS load/unload (#891)
     ams: {
-      load: 'Load',
-      unload: 'Unload',
+      load: 'ロード',
+      unload: 'アンロード',
     },
     bedJog: {
       title: 'ビルドプレートを移動',
@@ -741,7 +741,7 @@ export default {
       removeFromProject: 'プロジェクトから削除',
       loading: 'アーカイブを読み込み中...',
       noProjectsAvailable: '利用可能なプロジェクトがありません',
-      searchProjects: 'Search projects…',
+      searchProjects: 'プロジェクトを検索…',
       select: '選択',
       deselect: '選択解除',
       delete: '削除',
@@ -791,7 +791,7 @@ export default {
       estimated: '推定: {{time}}',
       actual: '実際: {{time}}',
       accuracy: '精度: {{percent}}%',
-      filament: '{{weight}}g',
+      filament: '{{weight}} g',
       layer: '{{count}} レイヤー',
       layers: '{{count}} レイヤー',
       object: '{{count}}オブジェクト',
@@ -809,7 +809,7 @@ export default {
       openInBambuStudioToSlice: 'スライサーでスライス',
       slice: 'スライス',
       externalLink: '外部リンク',
-      makerWorld: 'MakerWorld: {{designer}}',
+      makerWorld: 'MakerWorld {{designer}}',
       viewProject: 'プロジェクトを表示',
       noExternalLink: '外部リンクなし',
       preview3d: '3Dプレビュー',
@@ -842,7 +842,7 @@ export default {
       deleteArchive: 'アーカイブを削除',
       deleteConfirm: '"{{name}}" を削除しますか?この操作は取り消せません。',
       deleteButton: '削除',
-      deletePurgeStats: 'Also remove this print from Quick Stats (filament, time, cost, energy)',
+      deletePurgeStats: 'このプリントをQuick Statsからも削除(フィラメント、時間、コスト、電力)',
       removeSource3mf: 'ソース3MFを削除',
       removeSource3mfConfirm: '"{{name}}"からソース3MFファイルを削除してもよろしいですか?元のスライサープロジェクトファイルが削除されます。',
       removeButton: '削除',
@@ -1409,7 +1409,7 @@ export default {
       ldap: 'LDAP',
       twoFa: '二段階認証',
       oidc: 'SSO / OIDC',
-      security: 'Security',
+      security: 'セキュリティ',
     },
     spoolbuddy: {
       infoTitle: 'SpoolBuddy デバイス',
@@ -1758,14 +1758,14 @@ export default {
     manageQueueDescription: '印刷キューへのアイテムの追加と削除',
     controlPrinter: 'プリンターの制御',
     controlPrinterDescription: '印刷の一時停止、再開、停止',
-    cloudAccess: 'Allow cloud access',
-    cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
-    cloudBadge: 'Cloud',
-    updateEnergyCost: 'Update electricity price',
-    updateEnergyCostDescription: 'Allow this key to POST a new per-kWh electricity price to /settings/electricity-price. Useful for Home Assistant dynamic-tariff automations (Tibber, Octopus, etc.). This is the only settings field writable via API key.',
-    energyCostBadge: 'Energy',
-    legacyKey: 'Legacy',
-    legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
+    cloudAccess: 'クラウドアクセスを許可',
+    cloudAccessDescription: 'Bambu Cloudのプリセットとフィラメントを代わりに読み込みます。Bambu Cloudへのサインインが必要です。',
+    cloudBadge: 'クラウド',
+    updateEnergyCost: '電気料金を更新',
+    updateEnergyCostDescription: 'このキーが /settings/electricity-price に新しいkWhごとの電気料金をPOSTすることを許可。Home Assistantの動的料金自動化(Tibber、Octopusなど)に便利。これはAPIキーで書き込み可能な唯一の設定フィールドです。',
+    energyCostBadge: '電力',
+    legacyKey: 'レガシー',
+    legacyKeyTooltip: 'ユーザー所有以前に作成 - クラウドアクセスには再作成が必要',
     unnamedKey: '名前なしキー',
     lastUsed: '最終使用:',
     read: '読み取り',
@@ -1815,8 +1815,8 @@ export default {
     defaultLayerInspectDesc: 'AIによる第1層の検査',
     defaultTimelapse: 'タイムラプス',
     defaultTimelapseDesc: 'タイムラプス動画を記録',
-    staggeredStart: 'Staggered Start',
-    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggeredStart: '段階的開始',
+    staggeredStartDescription: '複数プリンターのバッチ開始を段階的に行う際のデフォルトのグループサイズと間隔。プリントモーダルでバッチごとに上書き可能。',
     plateClear: 'プレートクリア確認',
     requirePlateClear: 'プレートクリア確認を必須にする',
     requirePlateClearDescription: '有効にすると、スケジューラーは完了したプリンターでキューの印刷を開始する前に、プリンターごとのプレートクリア確認を待ちます。無効にすると、プリンターカード上のプレート状態バッジと「プレートをクリア済みにする」ボタンも非表示になります。',
@@ -1827,10 +1827,10 @@ export default {
     gcodeEndLabel: '終了G-code',
     gcodeStartPlaceholder: '印刷開始前に挿入されるG-code...',
     gcodeEndPlaceholder: '印刷終了後に追加されるG-code...',
-    staggerGroupSize: 'Group size',
-    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
-    staggerInterval: 'Interval (minutes)',
-    staggerIntervalHelp: 'Delay between each group starting',
+    staggerGroupSize: 'グループサイズ',
+    staggerGroupSizeHelp: 'グループあたり同時に開始するプリンター数',
+    staggerInterval: '間隔(分)',
+    staggerIntervalHelp: '各グループ開始までの遅延',
     queueDrying: '自動乾燥',
     queueDryingDescription: 'キュー印刷の合間にプリンターがアイドル状態の時、AMSフィラメントを自動的に乾燥します。上記の湿度しきい値を使用します。',
     queueDryingEnabled: '自動乾燥を有効にする',
@@ -1867,7 +1867,7 @@ export default {
     enterUsername: 'ユーザー名を入力',
     password: 'パスワード',
     enterPassword: 'パスワードを入力',
-    passwordRequirements: 'At least 8 characters, with one uppercase, one lowercase, one digit, and one special character.',
+    passwordRequirements: '大文字、小文字、数字、特殊文字を各1文字以上含む8文字以上。',
     confirmPassword: 'パスワードの確認',
     confirmPasswordPlaceholder: 'パスワードを確認',
     // Title tooltips
@@ -1898,28 +1898,28 @@ export default {
     embeddedOverlay: '埋め込みオーバーレイ',
     preferredSlicer: '優先スライサー',
     preferredSlicerDescription: 'ファイルを開くスライサーアプリケーションを選択',
-    orcaslicerKnownIssuesWarning: 'OrcaSlicer 2.3.2 / 2.4.0-dev have known CLI bugs that block slicing many Bambu-authored 3MFs — see upstream issues #12426 (segfault on painted multi-extruder files) and #13386 (parameter-range strict-validation reject). Bambu Studio is recommended until the upstream fixes land.',
-    useSlicerApi: 'Use Slicer API',
-    useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
-    slicerCard: 'Slicer',
+    orcaslicerKnownIssuesWarning: 'OrcaSlicer 2.3.2 / 2.4.0-devには、Bambu作成の3MFの多くをスライスできない既知のCLIバグがあります — 上流イシュー#12426(ペイント済みマルチエクストルーダーファイルでのsegfault)および#13386(パラメータ範囲の厳格検証拒否)を参照。上流の修正がリリースされるまで、Bambu Studioを推奨します。',
+    useSlicerApi: 'スライサーAPIを使用',
+    useSlicerApiDescription: 'オンの場合、「Slice」アクションはアプリ内スライサーモーダルを開き、slicer-APIサイドカーを呼び出します。オフ(デフォルト)の場合、URIスキーム経由でデスクトップスライサーに引き継がれます。',
+    slicerCard: 'スライサー',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
-    slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
+    slicerApiUrlDescription: 'slicer-APIサイドカーコンテナのURL。空のままにすると SLICER_API_URL / BAMBU_STUDIO_API_URL 環境変数のデフォルト値が使用されます。',
     slicerBundles: {
-      title: 'Slicer Bundles',
-      description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
-      uploadButton: 'Upload bundle',
-      uploading: 'Uploading…',
-      loading: 'Loading bundles…',
-      empty: 'No bundles imported yet.',
-      summary: '{{processCount}} process · {{filamentCount}} filament presets',
-      delete: 'Delete',
-      uploadSuccess: 'Imported {{name}}',
-      uploadError: 'Bundle upload failed: {{message}}',
-      deleteSuccess: 'Bundle removed',
-      deleteError: 'Bundle delete failed: {{message}}',
-      confirmDeleteTitle: 'Remove this bundle?',
-      confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
+      title: 'スライサーバンドル',
+      description: 'BambuStudioからエクスポートされたPrinter Preset Bundle(.bbscfg)をインポートします(ファイル → エクスポート → プリセットバンドルをエクスポート → "Printer preset bundle")。インポート後、スライス要求はJSONプロファイルトリプレットを再アップロードせずにバンドルから名前でプリセットを選択できます。',
+      uploadButton: 'バンドルをアップロード',
+      uploading: 'アップロード中…',
+      loading: 'バンドルを読み込み中…',
+      empty: 'バンドルはまだインポートされていません。',
+      summary: '{{processCount}}プロセス · {{filamentCount}}フィラメントプリセット',
+      delete: '削除',
+      uploadSuccess: '{{name}}をインポート済み',
+      uploadError: 'バンドルのアップロードに失敗: {{message}}',
+      deleteSuccess: 'バンドルを削除しました',
+      deleteError: 'バンドルの削除に失敗: {{message}}',
+      confirmDeleteTitle: 'このバンドルを削除しますか?',
+      confirmDeleteMessage: '「{{name}}」を参照するスライス要求は、バンドルを再インポートするまで失敗します。',
     },
     externalCameras: '外部カメラ',
     costTracking: 'コスト追跡',
@@ -1972,10 +1972,10 @@ export default {
       fillRequiredFields: '必須項目をすべて入力してください',
       passwordsDoNotMatch: 'パスワードが一致しません',
       passwordTooShort: 'パスワードは8文字以上必要です',
-      passwordNeedsUppercase: 'Password must contain at least one uppercase letter',
-      passwordNeedsLowercase: 'Password must contain at least one lowercase letter',
-      passwordNeedsDigit: 'Password must contain at least one digit',
-      passwordNeedsSpecial: 'Password must contain at least one special character',
+      passwordNeedsUppercase: 'パスワードには少なくとも1つの大文字を含める必要があります',
+      passwordNeedsLowercase: 'パスワードには少なくとも1つの小文字を含める必要があります',
+      passwordNeedsDigit: 'パスワードには少なくとも1つの数字を含める必要があります',
+      passwordNeedsSpecial: 'パスワードには少なくとも1つの特殊文字を含める必要があります',
       enterGroupName: 'グループ名を入力',
       settingsSaved: '設定を保存しました',
       noPermissionUpdate: '設定を変更する権限がありません',
@@ -2044,7 +2044,7 @@ export default {
       addNewColor: '新しいカラーを追加',
       manufacturer: 'メーカー',
       colorName: 'カラー名',
-      hex: 'Hex',
+      hex: '16進数',
       materialOptional: '素材(任意)',
       showing: '{{total}}件中{{filtered}}件を表示',
       noMatch: '検索に一致するカラーがありません',
@@ -2081,7 +2081,7 @@ export default {
     },
     // General tab
     dateFormat: '日付形式',
-    dateFormatUs: 'US (MM/DD/YYYY)',
+    dateFormatUs: '米国 (MM/DD/YYYY)',
     dateFormatEu: 'EU (DD/MM/YYYY)',
     dateFormatIso: 'ISO (YYYY-MM-DD)',
     timeFormat: '時刻形式',
@@ -2140,9 +2140,9 @@ export default {
     cameraTypeRtsp: 'RTSPストリーム',
     cameraTypeSnapshot: 'HTTPスナップショット',
     cameraTypeUsb: 'USBカメラ (V4L2)',
-    cameraSnapshotUrl: 'Snapshot URL (optional)',
+    cameraSnapshotUrl: 'スナップショットURL(任意)',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
-    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, layer-timelapse frames, and plate detection. Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
+    cameraSnapshotUrlHelp: '通知サムネイル、終了写真、レイヤータイムラプスフレーム、プレート検出に使用される単一フレームURL。タイムラプスとプレート検出はそれぞれプリンターごとのトグルが必要 — このURLはアクティブ時の画像ソースに過ぎません。上記のライブストリームから取得する場合は空のままにします。go2rtc(/api/frame.jpeg)や専用スナップショットエンドポイントを持つIPカメラに便利です。',
     cameraRotation: '回転',
     test: 'テスト',
     connected: '接続済み',
@@ -2180,7 +2180,7 @@ export default {
     // Network tab
     externalUrl: '外部URL',
     externalUrlDescription: 'Bambuddyがアクセス可能な外部URL。通知画像や外部連携に使用されます。',
-    bambuddyUrl: 'Bambuddy URL',
+    bambuddyUrl: 'BambuddyURL',
     externalUrlHint: 'プロトコルとポートを含めてください(例: http://192.168.1.100:8000)',
     ftpRetry: 'FTPリトライ',
     ftpRetryDescription: 'プリンターのWi-Fiが不安定な場合にFTP操作をリトライ。3MFダウンロード、印刷アップロード、タイムラプスダウンロード、ファームウェア更新に適用。',
@@ -2320,8 +2320,8 @@ export default {
         secretPlaceholder: '新しいシークレット',
         emailClaim: 'メールクレーム',
         emailClaimDesc: "メールIDとして使用するJWTクレーム。Azure Entra IDには'preferred_username'または'upn'を使用(email_verifiedを送信しない)。信頼できるクレーム名のみ使用してください。",
-        emailClaimPlaceholder: 'email',
-        emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
+        emailClaimPlaceholder: 'メール',
+        emailClaimCustomClaimAutoLinkWarning: 'カスタムクレームは、値がテナント管理されている場合(Azure Entra IDのupn / preferred_usernameなど)にのみ自動リンクに安全です。IdPがユーザーにこのクレームの自己宣言を許可している場合は、自動リンクを有効にしないでください。',
         requireEmailVerified: 'メール確認を要求',
         requireEmailVerifiedDesc: 'プロバイダーが確認済みとしてマークした場合にのみメールクレームを受け入れます。',
         requireEmailVerifiedWarning: '警告:確認なしでメールが受け入れられます。信頼できるプロバイダーのみで使用してください。',
@@ -2334,19 +2334,19 @@ export default {
 
     // TODO: translate encryption keys
     encryption: {
-      title: 'MFA Encryption Status',
-      enabledFromEnv: 'At-rest encryption enabled (key from MFA_ENCRYPTION_KEY environment variable)',
-      enabledFromFile: 'At-rest encryption enabled (key loaded from data directory)',
-      enabledGenerated: 'At-rest encryption enabled with auto-generated key',
-      notConfigured: 'At-rest encryption not configured',
-      notConfiguredDesc: 'TOTP secrets and OIDC client_secrets are stored in plaintext. Set MFA_ENCRYPTION_KEY or restart Bambuddy with a writable data directory to auto-generate one.',
-      allEncrypted: 'All MFA secrets are encrypted at rest.',
-      legacyRowsLabel: 'Legacy plaintext rows',
-      encryptedRowsLabel: 'Encrypted rows',
-      legacyRowsWarning: '{{count}} legacy plaintext row(s) detected. Re-save the OIDC provider or re-enroll the user’s authenticator app to migrate to encrypted storage.',
-      backupHint: 'The auto-generated key is stored at DATA_DIR/.mfa_encryption_key and is included in local backup ZIPs. Keep your backups secure or set MFA_ENCRYPTION_KEY explicitly.',
-      decryptionBrokenTitle: 'Encryption key missing',
-      decryptionBrokenError: '{{count}} encrypted record(s) cannot be decrypted because the encryption key is no longer available. Restore the previous MFA_ENCRYPTION_KEY or DATA_DIR/.mfa_encryption_key to recover.',
+      title: 'MFA暗号化ステータス',
+      enabledFromEnv: '保存時の暗号化が有効(MFA_ENCRYPTION_KEY環境変数のキー)',
+      enabledFromFile: '保存時の暗号化が有効(データディレクトリから読み込んだキー)',
+      enabledGenerated: '保存時の暗号化が自動生成キーで有効',
+      notConfigured: '保存時の暗号化が設定されていません',
+      notConfiguredDesc: 'TOTPシークレットとOIDCのclient_secretsは平文で保存されています。MFA_ENCRYPTION_KEYを設定するか、書き込み可能なデータディレクトリでBambuddyを再起動して自動生成してください。',
+      allEncrypted: 'すべてのMFAシークレットは保存時に暗号化されています。',
+      legacyRowsLabel: 'レガシー平文行',
+      encryptedRowsLabel: '暗号化された行',
+      legacyRowsWarning: '{{count}}件のレガシー平文行を検出。OIDCプロバイダーを再保存するか、ユーザーの認証アプリを再登録して暗号化ストレージへ移行してください。',
+      backupHint: '自動生成キーは DATA_DIR/.mfa_encryption_key に保存され、ローカルバックアップZIPに含まれます。バックアップを安全に保管するか、MFA_ENCRYPTION_KEYを明示的に設定してください。',
+      decryptionBrokenTitle: '暗号化キーが見つかりません',
+      decryptionBrokenError: '{{count}}件の暗号化レコードを復号できません。暗号化キーが見つかりません。以前のMFA_ENCRYPTION_KEYまたはDATA_DIR/.mfa_encryption_keyを復元してください。',
       migrationErrorWarning: '{{count}} 件のレガシー行を起動時に再暗号化できませんでした。サーバーログを確認し、Bambuddy を再起動して再試行してください。',
     },
 
@@ -2380,7 +2380,7 @@ export default {
     },
     printerError: {
       title: 'プリンターエラー',
-      body: '{{printer}}: {{error}}',
+      body: '{{printer}} {{error}}',
     },
     filamentLow: {
       title: 'フィラメント残量低下',
@@ -2718,7 +2718,7 @@ export default {
       fillRequired: '必須項目をすべて入力してください',
       passwordsDoNotMatch: 'パスワードが一致しません',
       passwordTooShort: 'パスワードは6文字以上必要です',
-      ldapProvisioned: 'Provisioned LDAP user "{{username}}"',
+      ldapProvisioned: 'LDAPユーザー「{{username}}」をプロビジョニング',
     },
     modal: {
       createUser: 'ユーザーを作成',
@@ -2729,21 +2729,21 @@ export default {
       saveChanges: '変更を保存',
       advancedAuthSubtitle: '高度な認証を使用',
       // Manual LDAP provisioning (#1298) — English fallbacks
-      tabsAriaLabel: 'User source',
-      localTab: 'Local',
+      tabsAriaLabel: 'ユーザーソース',
+      localTab: 'ローカル',
       ldapTab: 'LDAP',
-      ldapSearchLabel: 'Search directory',
-      ldapSearchPlaceholder: 'Type a username, name, or email...',
-      ldapMinChars: 'Type at least 2 characters to search',
-      ldapTypeToSearch: 'Start typing to search the LDAP directory',
-      ldapSearching: 'Searching directory...',
-      ldapNoResults: 'No matching users in the directory',
-      ldapSearchError: 'Directory search failed. Check the LDAP server status.',
-      ldapAlreadyProvisioned: 'Already provisioned',
-      ldapSelectedLabel: 'Selected',
-      ldapProvision: 'Provision user',
-      ldapProvisioning: 'Provisioning...',
-      ldapErrorProvision: 'Provisioning failed. Check the LDAP server status and try again.',
+      ldapSearchLabel: 'ディレクトリを検索',
+      ldapSearchPlaceholder: 'ユーザー名、名前、またはメールアドレスを入力...',
+      ldapMinChars: '検索するには2文字以上入力してください',
+      ldapTypeToSearch: 'LDAPディレクトリを検索するには入力を開始',
+      ldapSearching: 'ディレクトリを検索中...',
+      ldapNoResults: 'ディレクトリに一致するユーザーがいません',
+      ldapSearchError: 'ディレクトリ検索に失敗。LDAPサーバーの状態を確認してください。',
+      ldapAlreadyProvisioned: 'プロビジョニング済み',
+      ldapSelectedLabel: '選択中',
+      ldapProvision: 'ユーザーをプロビジョニング',
+      ldapProvisioning: 'プロビジョニング中...',
+      ldapErrorProvision: 'プロビジョニングに失敗。LDAPサーバーの状態を確認して再試行してください。',
     },
     form: {
       username: 'ユーザー名',
@@ -3106,7 +3106,7 @@ export default {
     noPermissionLinkFolder: 'フォルダーをリンクする権限がありません',
     noPermissionDeleteFolder: 'フォルダーを削除する権限がありません',
     noPermissionPrint: '印刷する権限がありません',
-    noPermissionSlice: 'You do not have permission to slice files',
+    noPermissionSlice: 'ファイルをスライスする権限がありません',
     noPermissionAddToQueue: 'キューに追加する権限がありません',
     noPermissionDownload: 'ファイルをダウンロードする権限がありません',
     noPermissionRenameFile: 'このファイル名を変更する権限がありません',
@@ -3401,47 +3401,47 @@ export default {
 
   // Slice (slicer-API integration via SliceModal)
   slice: {
-    title: 'Slice model',
-    action: 'Slice',
-    slicing: 'Slicing…',
-    printer: 'Printer profile',
-    process: 'Process profile',
-    filament: 'Filament profile',
-    filamentSlot: 'Filament {{index}} ({{type}})',
-    selectPreset: '— Select a preset —',
-    loadingPresets: 'Loading presets…',
-    analyzingPlateFilaments: 'Analyzing plate filaments…',
-    analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
-    previewToast: 'Analyzing {{name}} — {{elapsed}}',
-    previewWithProgress: 'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
-    notUsedByPlate: '— not used by this plate',
-    printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
-    noPresetsForSlot: 'No presets available',
-    presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
-    allPresetsRequired: 'All presets must be selected',
-    enqueuing: 'Submitting slice job…',
-    queued: 'Queued…',
-    failed: 'Slicing failed. Check the slicer sidecar logs.',
-    startedToast: 'Slicing {{name}} in the background…',
-    queuedToast: 'Queued: {{name}} — {{elapsed}}',
-    runningToast: 'Slicing {{name}} — {{elapsed}}',
-    runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
-    completedToast: 'Sliced {{name}}',
-    failedToast: 'Slicing {{name}} failed: {{detail}}',
+    title: 'モデルをスライス',
+    action: 'スライス',
+    slicing: 'スライス中…',
+    printer: 'プリンタープロファイル',
+    process: 'プロセスプロファイル',
+    filament: 'フィラメントプロファイル',
+    filamentSlot: 'フィラメント {{index}}({{type}})',
+    selectPreset: '— プリセットを選択 —',
+    loadingPresets: 'プリセットを読み込み中…',
+    analyzingPlateFilaments: 'プレートのフィラメントを分析中…',
+    analyzingPlateFilamentsHint: 'このプレートが使用するAMSスロットを検出するためにプレビュースライスを実行中。キャッシュ後は再オープンが即座になります。',
+    previewToast: '{{name}}を分析中 – {{elapsed}}',
+    previewWithProgress: '{{name}}を分析中 – {{stage}} ({{percent}}%) – {{elapsed}}',
+    notUsedByPlate: '— このプレートでは使用しない',
+    printerMismatch: 'この3MFは{{source}}用にスライスされていますが、{{target}}を選択しました。スライサーCLIは異なるプリンター用に3MFを再スライスできません — Bambu Studioでソースを開き、プリンターを変更して再エクスポートしてください。',
+    noPresetsForSlot: 'プリセットなし',
+    presetsLoadFailed: 'プリセットの読み込みに失敗。先に設定 → プロファイルからインポートしてください。',
+    allPresetsRequired: 'すべてのプリセットを選択する必要があります',
+    enqueuing: 'スライスジョブを送信中…',
+    queued: '待機中…',
+    failed: 'スライスに失敗。サイドカーのログを確認してください。',
+    startedToast: 'バックグラウンドで{{name}}をスライス中…',
+    queuedToast: '待機中: {{name}} – {{elapsed}}',
+    runningToast: '{{name}}をスライス中 – {{elapsed}}',
+    runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
+    completedToast: '{{name}}をスライス済み',
+    failedToast: '{{name}}のスライスに失敗: {{detail}}',
     tier: {
-      local: 'Imported',
-      cloud: 'Cloud',
-      standard: 'Standard',
+      local: 'インポート済み',
+      cloud: 'クラウド',
+      standard: '標準',
     },
     cloud: {
-      notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
-      expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
-      unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
+      notAuthenticated: 'Bambu Cloudにサインイン(設定 → プロファイル → クラウド)してクラウドプリセットを表示。',
+      expired: 'Bambu Cloudセッションの有効期限切れ – クラウドプリセットを更新するには再ログインしてください。',
+      unreachable: 'Bambu Cloudに接続できません。ローカルと標準のプリセットは引き続き使用できます。',
     },
     bedType: {
-      label: 'Build plate',
-      auto: 'Auto (use process preset)',
-      coolPlate: 'Cool Plate',
+      label: 'ビルドプレート',
+      auto: '自動(プロセスプリセットを使用)',
+      coolPlate: 'クールプレート',
       coolPlateSuperTack: 'Cool Plate SuperTack',
       engineering: 'Engineering Plate',
       highTemp: 'High Temp Plate',
@@ -3493,26 +3493,26 @@ export default {
     spoolmanMixedContentFixOpenNewTab: '回避策として Spoolman を新しいタブで HTTP として開くことができます — 混在コンテンツのルールは埋め込みフレームのみに適用され、独立したタブは問題なく動作します。',
     spoolmanOpenInNewTab: 'Spoolman を新しいタブで開く',
     labels: {
-      title: 'Print spool labels',
-      selectedCount: '{{count}} selected',
-      pickSpools: 'Pick which spools to print labels for:',
-      searchPlaceholder: 'Search name, brand, or #ID',
-      filterByMaterial: 'Material:',
-      allMaterials: 'All',
-      selectVisible: 'Select all visible ({{count}})',
-      deselectVisible: 'Deselect visible',
-      clearAll: 'Clear all',
-      noSpoolsToShow: 'No spools to show. Adjust your filter and try again.',
-      noMatches: 'No spools match the current search or filter.',
-      printOne: 'Print label for this spool',
-      printLabels: 'Print labels…',
-      bulkTitle: 'Pick spools to print labels for from the {{count}} currently shown',
-      noSpoolsTitle: 'No spools to label',
-      error: 'Could not generate labels: {{msg}}',
+      title: 'スプールラベルを印刷',
+      selectedCount: '{{count}}件選択中',
+      pickSpools: 'ラベルを印刷するスプールを選択:',
+      searchPlaceholder: '名前、ブランド、#IDで検索',
+      filterByMaterial: '素材:',
+      allMaterials: 'すべて',
+      selectVisible: '表示中をすべて選択 ({{count}})',
+      deselectVisible: '表示中を選択解除',
+      clearAll: 'すべてクリア',
+      noSpoolsToShow: '表示するスプールはありません。フィルターを調整して再試行してください。',
+      noMatches: '現在の検索またはフィルターに一致するスプールはありません。',
+      printOne: 'このスプールのラベルを印刷',
+      printLabels: 'ラベルを印刷…',
+      bulkTitle: '現在表示中の{{count}}件からラベルを印刷するスプールを選択',
+      noSpoolsTitle: 'ラベル付けするスプールなし',
+      error: 'ラベルを生成できませんでした: {{msg}}',
       templates: {
         ams: {
           label: 'AMS holder (30 × 15 mm)',
-          hint: 'Single label per page; fits the popular AMS filament label holder.',
+          hint: '1ページに1ラベル、人気のAMSフィラメントラベルホルダーに適合。',
         },
         box40x30: {
           label: 'ボックスラベル (40 × 30 mm)',
@@ -3520,15 +3520,15 @@ export default {
         },
         box: {
           label: 'Box label (62 × 29 mm)',
-          hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+          hint: '1ページに1ラベル、Brother PT/QLおよびDymoの小さなラベル用サイズ。',
         },
         averyL7160: {
           label: 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
-          hint: 'EU sheet stock; 21 labels per A4 page.',
+          hint: 'EUシート規格、A4ページに21枚のラベル。',
         },
         avery5160: {
           label: 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
-          hint: 'US sheet stock; 30 labels per Letter page.',
+          hint: '米国シート規格、Letterページに30枚のラベル。',
         },
       },
     },
@@ -3543,7 +3543,7 @@ export default {
     useCustomBrand: '「{{brand}}」を使用',
     useCustomMaterial: 'カスタム素材を使用: {{material}}',
     colorName: '色名',
-    colorNamePlaceholder: 'Jade White, Fire Red...',
+    colorNamePlaceholder: 'ジェイドホワイト、ファイアレッド...',
     color: '色',
     hexColor: 'HEXカラー',
     pickColor: 'カスタムカラーを選択',
@@ -3644,27 +3644,27 @@ export default {
     noColorsFound: '一致する色がありません',
     noResults: '結果なし',
     // Multi-color gradient + visual effect (#1154) — English fallback.
-    extraColorsLabel: 'Extra colors',
+    extraColorsLabel: '追加の色',
     extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
-    extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
-    extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
-    colorEffectLabel: 'Effect',
+    extraColorsHint: '2〜8個の16進数ストップをカンマで区切って貼り付けます。グラデーションとして表示されます。',
+    extraColorsInvalid: '無効な16進数を無視: {{tokens}}',
+    colorEffectLabel: 'エフェクト',
     colorEffect: {
-      none: 'None',
-      sparkle: 'Sparkle',
-      wood: 'Wood',
-      marble: 'Marble',
-      glow: 'Glow',
-      matte: 'Matte',
-      silk: 'Silk',
-      galaxy: 'Galaxy',
-      rainbow: 'Rainbow',
-      metal: 'Metal',
-      translucent: 'Translucent',
-      gradient: 'Gradient',
-      dualColor: 'Dual Color',
-      triColor: 'Tri Color',
-      multicolor: 'Multicolor',
+      none: 'なし',
+      sparkle: 'スパークル',
+      wood: 'ウッド',
+      marble: 'マーブル',
+      glow: 'グロー',
+      matte: 'マット',
+      silk: 'シルク',
+      galaxy: 'ギャラクシー',
+      rainbow: 'レインボー',
+      metal: 'メタル',
+      translucent: '半透明',
+      gradient: 'グラデーション',
+      dualColor: 'デュアルカラー',
+      triColor: 'トリカラー',
+      multicolor: 'マルチカラー',
     },
     // PA Profiles
     selectMaterialFirst: 'フィラメント情報タブで素材を選択してください。',
@@ -3743,7 +3743,7 @@ export default {
     weightConsumed: '消費重量',
     clearHistory: 'クリア',
     historyCleared: '使用履歴がクリアされました',
-    fillSourceLabel: '(Inv)',
+    fillSourceLabel: '(在庫)',
     lowStockThresholdError: 'しきい値は0.1から99.9の間でなければなりません',
     assignMismatchTitle: '材料の不一致',
     assignMismatchMessage: '選択したスプールの材料「{{spoolMaterial}}」は、{{location}} のトレイ材料「{{trayMaterial}}」と一致しません。割り当てますか?',
@@ -3835,19 +3835,19 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}}: 必要 {{required}}g、残り {{remaining}}g',
     printAnyway: 'それでも印刷',
     forceColorMatch: 'カラーマッチを強制',
-    staggerPrinterStarts: 'Stagger printer starts',
-    staggerGroupSize: 'Group size',
-    staggerInterval: 'Interval (min)',
-    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
-    staggerLastGroup: 'last group: {{count}}',
-    staggerTotal: 'total: {{minutes}} min',
+    staggerPrinterStarts: 'プリンター開始を段階的に',
+    staggerGroupSize: 'グループサイズ',
+    staggerInterval: '間隔(分)',
+    staggerPreview: '{{printers}}台のプリンター → {{size}}台ずつ{{groups}}グループ、{{interval}}分ごとに開始',
+    staggerLastGroup: '最終グループ: {{count}}',
+    staggerTotal: '合計: {{minutes}}分',
     staggerToPrinters: '{{count}}台のプリンターに段階的に送信',
     gcodeInjection: '自動印刷G-codeを挿入',
   },
 
   // Backup
   backup: {
-    includesEncryptionKey: 'Local backups include the MFA encryption key file (DATA_DIR/.mfa_encryption_key) so a backup ZIP is self-contained. Treat the ZIP as sensitive — anyone with the file can decrypt the OIDC client secrets and TOTP secrets stored inside.',
+    includesEncryptionKey: 'ローカルバックアップにはMFA暗号化キーファイル(DATA_DIR/.mfa_encryption_key)が含まれ、バックアップZIPは自己完結型です。ZIPを機密扱いしてください — ファイルを持つ人は誰でも内部のOIDCクライアントシークレットとTOTPシークレットを復号できます。',
     title: 'バックアップと復元',
     createBackup: 'バックアップを作成',
     restoreBackup: 'バックアップの復元',
@@ -3885,7 +3885,7 @@ export default {
     enterNewToken: '新しいトークンを入力して更新',
     tokenHint: 'Contents読み書き権限を持つきめ細かいトークン',
     branch: 'ブランチ',
-    provider: 'Git Provider',
+    provider: 'Gitプロバイダー',
     providerGitHub: 'GitHub',
 	providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
@@ -4005,27 +4005,27 @@ export default {
     close: '閉じる',
 
     // Scheduled local backups (#884)
-    scheduledBackup: 'Scheduled Backups',
-    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',
-    frequency: 'Frequency',
-    backupTime: 'Time',
-    retention: 'Retention',
-    retentionDescription: 'Number of backups to keep',
-    outputPath: 'Output Path',
-    outputPathPlaceholder: 'Default: {{path}}',
-    outputPathDescription: 'Leave empty for default location',
-    runNow: 'Run Now',
-    backupFiles: 'Backup Files',
-    noScheduledBackups: 'No backups yet',
-    deleteBackup: 'Delete',
-    deleteBackupConfirm: 'Delete this backup file?',
-    backupRunning: 'Backup in progress...',
-    scheduledBackupComplete: 'Backup completed successfully',
-    scheduledBackupFailed: 'Backup failed',
-    nextBackup: 'Next backup',
-    backupSize: 'Size',
+    scheduledBackup: 'スケジュールされたバックアップ',
+    scheduledBackupDescription: 'スケジュールに基づいてバックアップスナップショットを自動作成。出力ディレクトリはNASや外部ストレージにマウント可能。',
+    frequency: '頻度',
+    backupTime: '時間',
+    retention: '保持期間',
+    retentionDescription: '保持するバックアップ数',
+    outputPath: '出力パス',
+    outputPathPlaceholder: 'デフォルト: {{path}}',
+    outputPathDescription: 'デフォルトの場所を使用する場合は空白のまま',
+    runNow: '今すぐ実行',
+    backupFiles: 'バックアップファイル',
+    noScheduledBackups: 'バックアップなし',
+    deleteBackup: '削除',
+    deleteBackupConfirm: 'このバックアップファイルを削除しますか?',
+    backupRunning: 'バックアップ中...',
+    scheduledBackupComplete: 'バックアップが正常に完了しました',
+    scheduledBackupFailed: 'バックアップに失敗',
+    nextBackup: '次回バックアップ',
+    backupSize: 'サイズ',
     utc: 'UTC',
-    defaultPathLabel: 'Default:',
+    defaultPathLabel: 'デフォルト:',
 
     // Category labels
     categories: {
@@ -4289,12 +4289,12 @@ export default {
       description: 'キューに追加されたときに自動的に印刷を開始します。オフの場合、手動ディスパッチを待ちます。',
     },
     queueForceColorMatch: {
-      title: 'Force color match',
-      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
+      title: '色の一致を強制',
+      description: '正確なフィラメントタイプと色がロードされていないプリンターへの送信を拒否します。デフォルトはオフ — これがないと、キューはモデルのみのマッチングを使用し、間違った色がロードされたプリンターを選ぶ可能性があります。',
     },
     tailscaleDisabled: {
       title: 'Tailscale統合',
-      description: 'Enable to mark this VP as exposed over Tailscale. Shows the host\'s Tailscale address so you know which IP to paste into the slicer. The CA-import step is unchanged — this toggle has no effect on certificates.',
+      description: 'このVPがTailscale経由で公開されていることをマークするには有効にしてください。スライサーに貼り付けるIPがわかるよう、ホストのTailscaleアドレスを表示します。CAインポート手順は変更されません — このトグルは証明書に影響しません。',
     },
     setupRequired: {
       title: 'セットアップが必要です',
@@ -4545,32 +4545,32 @@ export default {
     mqttEnergyHint: 'JSONパスはJSONペイロードから値を抽出します。生の値の場合は空のままにしてください。\n乗数: Wh→kWhは0.001、MWh→kWhは1000を使用。',
     mqttStateHint: 'JSONパスはJSONペイロードから値を抽出します。生の値の場合は空のままにしてください。\nON値: "ON"を意味する正確な文字列。自動検出(ON、true、1)の場合は空のままにしてください。',
     // REST smart plug
-    restControl: 'Control',
-    restOnUrl: 'Turn ON URL',
-    restOffUrl: 'Turn OFF URL',
-    restOnBody: 'ON Request Body',
-    restOffBody: 'OFF Request Body',
-    restMethod: 'HTTP Method',
+    restControl: 'コントロール',
+    restOnUrl: 'オンURL',
+    restOffUrl: 'オフURL',
+    restOnBody: 'ONリクエスト本文',
+    restOffBody: 'OFFリクエスト本文',
+    restMethod: 'HTTPメソッド',
     restHeaders: 'Custom Headers (JSON)',
-    restStatusUrl: 'Status URL',
-    restStatusPath: 'State JSON Path',
-    restStatusOnValue: 'ON Value',
+    restStatusUrl: 'ステータスURL',
+    restStatusPath: '状態JSONパス',
+    restStatusOnValue: 'ON',
     restPowerUrl: '電力URL',
-    restPowerPath: 'Power JSON Path',
+    restPowerPath: '電力JSONパス',
     restPowerMultiplier: '電力乗数',
     restEnergyUrl: 'エネルギーURL',
-    restEnergyPath: 'Energy JSON Path',
+    restEnergyPath: '電力量JSONパス',
     restEnergyMultiplier: 'エネルギー乗数',
-    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
-    restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
-    restBodyHint: 'e.g. ON, {"state": "on"}',
-    restStatusHint: 'URL to poll for current state',
-    restPathHint: 'e.g. state or data.power.status',
+    restUrlRequired: 'RESTプラグには少なくとも1つのURL(ONまたはOFF)が必要',
+    restHeadersHint: '例: {"Authorization": "Bearer your-token"}',
+    restBodyHint: '例:ON, {"state": "on"}',
+    restStatusHint: '現在の状態を取得するURL',
+    restPathHint: '例: state または data.power.status',
     restPowerUrlHint: '電力データ用の個別URL(空欄の場合はステータスURLを使用)',
     restEnergyUrlHint: 'エネルギーデータ用の個別URL(空欄の場合はステータスURLを使用)',
     restEnergyHint: '各値は個別のURLを使用するか、ステータスURLにフォールバックできます。乗数で単位変換が可能です(例:WhからkWhへの変換は0.001)。',
-    testConnection: 'Test Connection',
-    connectionSuccess: 'Connection successful',
+    testConnection: '接続テスト',
+    connectionSuccess: '接続成功',
     noSwitchesInSwitchbar: 'スイッチバーにスイッチがありません',
     enableSwitchbarHint: '設定 > スマートプラグで「スイッチバーに表示」を有効にしてください',
   },
@@ -4748,7 +4748,7 @@ export default {
     password: 'パスワード',
     fromEmail: '送信元メール',
     toEmail: '宛先メール',
-    webhookUrl: 'Webhook URL',
+    webhookUrl: 'WebhookURL',
     payloadFormat: 'ペイロード形式',
     authorization: '認可',
     titleFieldName: 'タイトルフィールド名',
@@ -5029,7 +5029,7 @@ export default {
     showLessColors: '色を減らす',
     showMoreColors: '色をもっと表示',
     clear: 'クリア',
-    hexLabel: 'Hex: #{{hex}}',
+    hexLabel: '16進: #{{hex}}',
     resetting: 'リセット中...',
     resetSlot: 'スロットをリセット',
     cancel: 'キャンセル',
@@ -5049,7 +5049,7 @@ export default {
   // Email Settings
   emailSettings: {
     placeholders: {
-      fromName: 'BamBuddy',
+      fromName: 'Bambuddy',
     },
   },
 
@@ -5299,7 +5299,7 @@ export default {
       nfcReader: 'NFCリーダー',
       type: 'タイプ',
       connection: '接続',
-      notConnected: 'N/A',
+      notConnected: '該当なし',
       deviceInfo: 'デバイス情報',
       hostname: 'ホスト',
       uptime: '稼働時間',
@@ -5458,8 +5458,8 @@ export default {
     actionPauseOff: '一時停止して電源を切る',
     pollInterval: 'ポーリング間隔(秒)',
     pollIntervalHint: '印刷中に各プリンターをチェックする頻度。最小 5 秒、最大 120 秒。',
-    externalUrlMissing: 'External URL is not set.',
-    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',
+    externalUrlMissing: '外部URLが設定されていません。',
+    externalUrlHint: 'ML APIはURLでカメラスナップショットを取得します。MLAPIコンテナがBambuddyに到達できるよう、一般設定で外部URLを設定してください。',
     perPrinterTitle: '監視対象プリンター',
     perPrinterHint: '検出サービスが監視するプリンターを選択します。',
     monitorAll: '接続されているすべてのプリンターを監視',
@@ -5492,8 +5492,8 @@ export default {
     plateDefaultName: 'プレート {{n}}',
     materialCount: 'フィラメント {{count}} 本',
     amsRequired: 'AMS が必要',
-    slicedFor: 'Sliced for {{printer}}',
-    alsoCompatible: 'Also marked compatible: {{printers}}',
+    slicedFor: '{{printer}}用にスライス済み',
+    alsoCompatible: '互換性ありとも記録: {{printers}}',
     importToLibrary: '保存',
     sliceIn: '保存して {{slicer}} でスライス',
     disclaimer: 'MakerWorld 連携はコミュニティで文書化された API エンドポイントを使用しています。Bambuddy は MakerWorld または Bambu Lab との提携・承認関係はありません。',
@@ -5588,11 +5588,11 @@ export default {
     ageLabel: '次より古いファイルを移動',
     days: '日',
     includeNeverPrinted: '一度も印刷していないファイルも含める',
-    effectsTitle: 'What happens when you click Purge',
-    effect1: 'Matching files are moved to Trash — they are not deleted from disk yet.',
-    effect2: 'You can restore them from Trash at any time until the retention window expires.',
-    effect3: 'After retention, the trash sweeper permanently removes them from disk.',
-    effect4: 'Files in external (linked) folders are skipped — Bambuddy never deletes bytes it does not own.',    previewLoading: '対象ファイル数を確認中…',
+    effectsTitle: '「削除」をクリックすると何が起こるか',
+    effect1: '一致するファイルはゴミ箱に移動されます。ディスクからはまだ削除されません。',
+    effect2: '保持期間が過ぎるまで、いつでもゴミ箱から復元できます。',
+    effect3: '保持期間が過ぎると、ゴミ箱クリーナーがディスクから完全に削除します。',
+    effect4: '外部(リンク済み)フォルダー内のファイルはスキップされます — Bambuddyは所有していないバイトを削除しません。',    previewLoading: '対象ファイル数を確認中…',
     previewFailed: 'プレビューを取得できませんでした。',
     previewSummary: '{{count}} 件 · {{size}} がゴミ箱に移動されます',
     andMore: '…ほか {{count}} 件',
@@ -5614,87 +5614,87 @@ export default {
     saveFailed: '自動削除の設定を保存できませんでした。',
   },
   archivePurge: {
-    headerButton: 'Purge old',
-    headerTooltip: 'Bulk-delete old archives',
-    title: 'Purge old archives',
-    description: 'Clear out old print history. Each archive is aged by its most recent print completion — reprinting an archive refreshes its age, so active work is never purged.',
-    ageLabel: 'Delete archives not printed in the last',
-    days: 'days',
-    effectsTitle: 'What happens when you click Purge',
-    effect1: 'Each matching archive is permanently removed from the database.',
-    effect2: 'The 3MF, thumbnail, timelapse, source 3MF, F3D design file, and photo folder are all deleted from disk.',
-    effect3: 'There is no trash bin for archives — deletion is immediate and cannot be undone.',
-    effect4: 'Reprinting an archive refreshes its age clock, so archives you still use are safe.',
-    previewLoading: 'Checking how many archives match…',
-    previewFailed: 'Could not preview the purge.',
-    previewSummary: '{{count}} archives · {{size}} would be deleted',
-    andMore: '…and {{count}} more',
-    warning: 'This is permanent. Download or favourite anything you want to keep before continuing.',
-    confirmCta: 'Delete {{count}} archive(s)',
-    purging: 'Deleting…',
+    headerButton: '古いものを削除',
+    headerTooltip: '古いアーカイブを一括削除',
+    title: '古いアーカイブを削除',
+    description: '古い印刷履歴をクリア。各アーカイブは最新の印刷完了に基づいてエイジングされます — アーカイブを再印刷するとエイジがリセットされるため、現在使用中の作業が削除されることはありません。',
+    ageLabel: '印刷されていないアーカイブを削除(過去',
+    days: '',
+    effectsTitle: '「削除」をクリックすると何が起こるか',
+    effect1: '一致する各アーカイブはデータベースから完全に削除されます。',
+    effect2: '3MF、サムネイル、タイムラプス、ソース3MF、F3Dデザインファイル、写真フォルダーがすべてディスクから削除されます。',
+    effect3: 'アーカイブにはゴミ箱がありません — 削除は即座に行われ、元に戻せません。',
+    effect4: 'アーカイブを再印刷するとエイジクロックがリセットされるため、まだ使用中のアーカイブは安全です。',
+    previewLoading: '一致するアーカイブの数を確認中…',
+    previewFailed: '削除のプレビューを表示できませんでした。',
+    previewSummary: '{{count}}件のアーカイブ · {{size}}が削除されます',
+    andMore: '…他に{{count}}件',
+    warning: 'この操作は元に戻せません。続行する前に、保持したいものをダウンロードまたはお気に入りに追加してください。',
+    confirmCta: '{{count}}件のアーカイブを削除',
+    purging: '削除中…',
     toast: {
-      success: 'Deleted {{count}} archive(s).',
-      failed: 'Could not purge archives.',
+      success: '{{count}}件のアーカイブを削除しました。',
+      failed: 'アーカイブを削除できませんでした。',
     },
   },
   archiveAutoPurge: {
-    enableLabel: 'Auto-purge old archives',
-    enableDescription: 'Once per day, permanently deletes archives that have not been printed within the threshold. Reprinting an archive resets the clock. No trash bin — deletion is immediate.',
-    ageLabel: 'Auto-delete archives not printed in the last',
-    ageDescription: 'Minimum 7 days, maximum 10 years. Based on the most recent print completion — reprinting an archive refreshes its age. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
-    days: 'days',
-    runNow: 'Purge archives now',
-    saveFailed: 'Could not save auto-purge settings.',
+    enableLabel: '古いアーカイブを自動削除',
+    enableDescription: '1日1回、しきい値内に印刷されていないアーカイブを完全に削除します。再印刷するとタイマーがリセットされます。ゴミ箱なし — 削除は即座に行われます。',
+    ageLabel: '印刷されていないアーカイブを自動削除(過去',
+    ageDescription: '最小7日、最大10年。最新の印刷完了に基づきます — 再印刷するとエイジがリセットされます。アーカイブ、3MF、サムネイル、タイムラプス、写真を削除します。',
+    days: '',
+    runNow: 'アーカイブを今すぐ削除',
+    saveFailed: '自動削除設定を保存できませんでした。',
   },
   cameraTokens: {
-    title: 'Camera API Tokens',
-    navTitle: 'Camera API tokens',
+    title: 'カメラAPIトークン',
+    navTitle: 'カメラAPIトークン',
     description:
-      'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.',
-    loading: 'Loading…',
+      'Home Assistant、Frigate、キオスク、その他安定したURLが必要なツールにカメラストリームを埋め込むための長期トークン。各トークンはカメラストリーム専用で、いつでも取り消し可能。',
+    loading: '読み込み中…',
     confirmRevoke: {
-      title: 'Revoke this token?',
-      body: 'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
-      cancel: 'Cancel',
-      confirm: 'Revoke',
+      title: 'このトークンを取り消しますか?',
+      body: '「{{name}}」を使用しているデバイスは直ちにアクセスできなくなります。元に戻せません。',
+      cancel: 'キャンセル',
+      confirm: '取り消し',
     },
     create: {
-      title: 'Create new token',
-      nameLabel: 'Token name',
-      namePlaceholder: 'e.g. Home Assistant',
-      daysLabel: 'Days until expiry',
-      submit: 'Create',
+      title: '新しいトークンを作成',
+      nameLabel: 'トークン名',
+      namePlaceholder: '例:Home Assistant',
+      daysLabel: '有効期限までの日数',
+      submit: '作成',
       hint:
-        'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
+        '最大有効期間は365日。トークン値は作成時に一度だけ表示されます — 今すぐコピーしてください。',
     },
     created: {
-      title: 'Token created — copy it now',
+      title: 'トークンを作成しました – 今すぐコピー',
       warning:
-        'This is the only time this token will be visible. After you close this dialog you can never view it again.',
-      copy: 'Copy',
-      dismiss: "I've saved it",
+        'このトークンが表示されるのは今回限りです。このダイアログを閉じると二度と表示できません。',
+      copy: 'コピー',
+      dismiss: '保存しました',
     },
     list: {
-      myTitle: 'My tokens',
-      allTitle: 'All users (admin view)',
-      empty: 'No tokens yet.',
-      name: 'Name',
-      owner: 'Owner',
-      prefix: 'Prefix',
-      created: 'Created',
-      expires: 'Expires',
-      lastUsed: 'Last used',
-      revoke: 'Revoke',
-      expired: 'Expired',
+      myTitle: 'マイトークン',
+      allTitle: '全ユーザー(管理者ビュー)',
+      empty: 'トークンはまだありません。',
+      name: '名前',
+      owner: '所有者',
+      prefix: 'プレフィックス',
+      created: '作成',
+      expires: '有効期限',
+      lastUsed: '最終使用',
+      revoke: '取り消し',
+      expired: '期限切れ',
     },
     toast: {
-      created: 'Token created',
-      createFailed: 'Failed to create token',
-      revoked: 'Token revoked',
-      revokeFailed: 'Failed to revoke token',
-      loadFailed: 'Failed to load tokens',
-      copied: 'Copied to clipboard',
-      copyFailed: 'Copy failed — select and copy manually',
+      created: 'トークンを作成しました',
+      createFailed: 'トークンの作成に失敗',
+      revoked: 'トークンを取り消しました',
+      revokeFailed: 'トークンの取り消しに失敗',
+      loadFailed: 'トークンの読み込みに失敗',
+      copied: 'クリップボードにコピー',
+      copyFailed: 'コピーに失敗 – 手動で選択してコピー',
     },
   },
 

Fichier diff supprimé car celui-ci est trop grand
+ 314 - 314
frontend/src/i18n/locales/pt-BR.ts


+ 259 - 259
frontend/src/i18n/locales/zh-CN.ts

@@ -318,10 +318,10 @@ export default {
       calibrationSaved: '校准已保存!',
       calibrationFailed: '校准失败',
       rfidRereadInitiated: '已发起 RFID 重新读取',
-      loadInitiated: 'Loading filament…',
-      unloadInitiated: 'Unloading filament…',
-      failedToLoad: 'Failed to load filament',
-      failedToUnload: 'Failed to unload filament',
+      loadInitiated: '加载耗材中…',
+      unloadInitiated: '卸载耗材中…',
+      failedToLoad: '加载耗材失败',
+      failedToUnload: '卸载耗材失败',
     },
     // Connection status
     connection: {
@@ -347,8 +347,8 @@ export default {
     },
     // AMS load/unload (#891)
     ams: {
-      load: 'Load',
-      unload: 'Unload',
+      load: '加载',
+      unload: '卸载',
     },
     bedJog: {
       title: '移动热床',
@@ -742,7 +742,7 @@ export default {
       removeFromProject: '从项目中移除',
       loading: '加载中...',
       noProjectsAvailable: '无可用项目',
-      searchProjects: 'Search projects…',
+      searchProjects: '搜索项目…',
       select: '选择',
       deselect: '取消选择',
       delete: '删除',
@@ -792,7 +792,7 @@ export default {
       estimated: '预计:{{time}}',
       actual: '实际:{{time}}',
       accuracy: '准确度:{{percent}}%',
-      filament: '{{weight}}g',
+      filament: '{{weight}}',
       layer: '{{count}} 层',
       layers: '{{count}} 层',
       object: '{{count}} 个对象',
@@ -843,7 +843,7 @@ export default {
       deleteArchive: '删除归档',
       deleteConfirm: '确定要删除"{{name}}"吗?此操作无法撤销。',
       deleteButton: '删除',
-      deletePurgeStats: 'Also remove this print from Quick Stats (filament, time, cost, energy)',
+      deletePurgeStats: '同时从快速统计中删除此打印(耗材、时间、成本、能耗)',
       removeSource3mf: '移除源 3MF',
       removeSource3mfConfirm: '确定要从"{{name}}"中移除源 3MF 文件吗?这将删除原始切片项目文件。',
       removeButton: '移除',
@@ -1410,7 +1410,7 @@ export default {
       ldap: 'LDAP',
       twoFa: '双因素认证',
       oidc: 'SSO / OIDC',
-      security: 'Security',
+      security: '安全',
     },
     spoolbuddy: {
       infoTitle: 'SpoolBuddy 设备',
@@ -1760,14 +1760,14 @@ export default {
     manageQueueDescription: '添加和移除打印队列中的项目',
     controlPrinter: '控制打印机',
     controlPrinterDescription: '暂停、继续和停止打印',
-    cloudAccess: 'Allow cloud access',
-    cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
-    cloudBadge: 'Cloud',
-    updateEnergyCost: 'Update electricity price',
-    updateEnergyCostDescription: 'Allow this key to POST a new per-kWh electricity price to /settings/electricity-price. Useful for Home Assistant dynamic-tariff automations (Tibber, Octopus, etc.). This is the only settings field writable via API key.',
-    energyCostBadge: 'Energy',
-    legacyKey: 'Legacy',
-    legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
+    cloudAccess: '允许云端访问',
+    cloudAccessDescription: '代表您读取 Bambu Cloud 预设和耗材。需要登录 Bambu Cloud。',
+    cloudBadge: '云端',
+    updateEnergyCost: '更新电价',
+    updateEnergyCostDescription: '允许此密钥向 /settings/electricity-price POST 新的每千瓦时电价。适用于 Home Assistant 动态电价自动化(Tibber、Octopus 等)。这是唯一可通过 API 密钥写入的设置字段。',
+    energyCostBadge: '能耗',
+    legacyKey: '传统',
+    legacyKeyTooltip: '在按用户所有权之前创建;需重建以使用云端访问',
     unnamedKey: '未命名密钥',
     lastUsed: '上次使用',
     read: '读取',
@@ -1817,8 +1817,8 @@ export default {
     defaultLayerInspectDesc: 'AI首层检测',
     defaultTimelapse: '延时摄影',
     defaultTimelapseDesc: '录制延时摄影视频',
-    staggeredStart: 'Staggered Start',
-    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggeredStart: '错峰启动',
+    staggeredStartDescription: '错峰启动多台打印机批次时的默认组大小和间隔。可在打印对话框中按批次覆盖。',
     plateClear: '热床清空确认',
     requirePlateClear: '需要热床清空确认',
     requirePlateClearDescription: '启用后,调度器会在已完成打印的打印机上启动排队打印之前,等待每台打印机的热床清空确认。禁用后,也会隐藏打印机卡片上的打印板状态标记和“将打印板标记为已清理”按钮。',
@@ -1829,10 +1829,10 @@ export default {
     gcodeEndLabel: '结束G-code',
     gcodeStartPlaceholder: '在打印开始前插入的G-code...',
     gcodeEndPlaceholder: '在打印结束后追加的G-code...',
-    staggerGroupSize: 'Group size',
-    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
-    staggerInterval: 'Interval (minutes)',
-    staggerIntervalHelp: 'Delay between each group starting',
+    staggerGroupSize: '组大小',
+    staggerGroupSizeHelp: '每组同时启动的打印机数量',
+    staggerInterval: '间隔(分钟)',
+    staggerIntervalHelp: '每组启动之间的延迟',
     queueDrying: '自动干燥',
     queueDryingDescription: '在队列打印之间,打印机空闲时自动干燥AMS耗材。使用上方的湿度阈值触发干燥。',
     queueDryingEnabled: '启用自动干燥',
@@ -1869,7 +1869,7 @@ export default {
     enterUsername: '输入用户名',
     password: '密码',
     enterPassword: '输入密码',
-    passwordRequirements: 'At least 8 characters, with one uppercase, one lowercase, one digit, and one special character.',
+    passwordRequirements: '至少 8 个字符,包含一个大写、一个小写、一个数字和一个特殊字符。',
     confirmPassword: '确认密码',
     confirmPasswordPlaceholder: '确认密码',
     // Title tooltips
@@ -1900,28 +1900,28 @@ export default {
     embeddedOverlay: '嵌入式叠加层',
     preferredSlicer: '首选切片软件',
     preferredSlicerDescription: '选择要用于打开文件的切片软件',
-    orcaslicerKnownIssuesWarning: 'OrcaSlicer 2.3.2 / 2.4.0-dev have known CLI bugs that block slicing many Bambu-authored 3MFs — see upstream issues #12426 (segfault on painted multi-extruder files) and #13386 (parameter-range strict-validation reject). Bambu Studio is recommended until the upstream fixes land.',
-    useSlicerApi: 'Use Slicer API',
-    useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
-    slicerCard: 'Slicer',
+    orcaslicerKnownIssuesWarning: 'OrcaSlicer 2.3.2 / 2.4.0-dev 存在已知 CLI 错误,无法切片许多 Bambu 创建的 3MF — 见上游 issue #12426(绘制的多挤出机文件 segfault)和 #13386(参数范围严格验证拒绝)。在上游修复发布之前,推荐使用 Bambu Studio。',
+    useSlicerApi: '使用切片器 API',
+    useSlicerApiDescription: '开启时,「切片」操作打开应用内切片器模态并调用 slicer-API sidecar。关闭时(默认),通过 URI 方案交给桌面切片器。',
+    slicerCard: '切片器',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
-    slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
+    slicerApiUrlDescription: 'slicer-API sidecar 容器的 URL。留空以使用 SLICER_API_URL / BAMBU_STUDIO_API_URL 环境变量默认值。',
     slicerBundles: {
-      title: 'Slicer Bundles',
-      description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
-      uploadButton: 'Upload bundle',
-      uploading: 'Uploading…',
-      loading: 'Loading bundles…',
-      empty: 'No bundles imported yet.',
-      summary: '{{processCount}} process · {{filamentCount}} filament presets',
-      delete: 'Delete',
-      uploadSuccess: 'Imported {{name}}',
-      uploadError: 'Bundle upload failed: {{message}}',
-      deleteSuccess: 'Bundle removed',
-      deleteError: 'Bundle delete failed: {{message}}',
-      confirmDeleteTitle: 'Remove this bundle?',
-      confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
+      title: '切片器捆绑包',
+      description: '导入从 BambuStudio 导出的 Printer Preset Bundle (.bbscfg)(文件 → 导出 → 导出预设捆绑包 → "Printer preset bundle")。导入后,切片请求可以按名称从捆绑包中选择预设,无需重新上传 JSON 配置三元组。',
+      uploadButton: '上传捆绑包',
+      uploading: '上传中…',
+      loading: '加载捆绑包中…',
+      empty: '尚未导入捆绑包。',
+      summary: '{{processCount}} 个工艺 · {{filamentCount}} 个耗材预设',
+      delete: '删除',
+      uploadSuccess: '已导入 {{name}}',
+      uploadError: '上传捆绑包失败:{{message}}',
+      deleteSuccess: '捆绑包已移除',
+      deleteError: '删除捆绑包失败:{{message}}',
+      confirmDeleteTitle: '移除此捆绑包?',
+      confirmDeleteMessage: '引用「{{name}}」的切片请求将失败,直到捆绑包重新导入。',
     },
     externalCameras: '外部摄像头',
     costTracking: '成本追踪',
@@ -1974,10 +1974,10 @@ export default {
       fillRequiredFields: '请填写所有必填字段',
       passwordsDoNotMatch: '密码不匹配',
       passwordTooShort: '密码至少需要 8 个字符',
-      passwordNeedsUppercase: 'Password must contain at least one uppercase letter',
-      passwordNeedsLowercase: 'Password must contain at least one lowercase letter',
-      passwordNeedsDigit: 'Password must contain at least one digit',
-      passwordNeedsSpecial: 'Password must contain at least one special character',
+      passwordNeedsUppercase: '密码必须至少包含一个大写字母',
+      passwordNeedsLowercase: '密码必须至少包含一个小写字母',
+      passwordNeedsDigit: '密码必须至少包含一个数字',
+      passwordNeedsSpecial: '密码必须至少包含一个特殊字符',
       enterGroupName: '请输入组名称',
       settingsSaved: '设置已保存',
       noPermissionUpdate: '您没有权限更改设置',
@@ -2138,9 +2138,9 @@ export default {
     cameraTypeRtsp: 'RTSP 流',
     cameraTypeSnapshot: 'HTTP 快照',
     cameraTypeUsb: 'USB 摄像头 (V4L2)',
-    cameraSnapshotUrl: 'Snapshot URL (optional)',
+    cameraSnapshotUrl: '快照 URL(可选)',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
-    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, layer-timelapse frames, and plate detection. Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
+    cameraSnapshotUrlHelp: '用于通知缩略图、完成照片、层延时摄影帧和打印板检测的单帧 URL。延时摄影和打印板检测各自需要自己的每打印机开关 — 此 URL 只是它们激活时拉取的图像源。留空以从上方实时流捕获。适用于 go2rtc(/api/frame.jpeg)和具有专用快照端点的 IP 摄像头。',
     cameraRotation: '旋转',
     test: '测试',
     connected: '已连接',
@@ -2308,8 +2308,8 @@ export default {
         secretPlaceholder: '新密钥',
         emailClaim: '邮箱声明',
         emailClaimDesc: "用作邮箱身份的 JWT 声明。Azure Entra ID 请使用 'preferred_username' 或 'upn'(不发送 email_verified)。仅使用可信的声明名称。",
-        emailClaimPlaceholder: 'email',
-        emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
+        emailClaimPlaceholder: '邮箱',
+        emailClaimCustomClaimAutoLinkWarning: '自定义声明仅在值由租户管理时(例如 Azure Entra ID 的 upn / preferred_username)对自动关联安全。如果您的 IdP 允许用户自我声明此声明,请勿启用自动关联。',
         requireEmailVerified: '要求邮箱已验证',
         requireEmailVerifiedDesc: '仅在提供商将邮箱声明标记为已验证时才接受。',
         requireEmailVerifiedWarning: '警告:将在未经验证的情况下接受邮箱。仅对受信任的提供商使用。',
@@ -2322,19 +2322,19 @@ export default {
 
     // TODO: translate encryption keys
     encryption: {
-      title: 'MFA Encryption Status',
-      enabledFromEnv: 'At-rest encryption enabled (key from MFA_ENCRYPTION_KEY environment variable)',
-      enabledFromFile: 'At-rest encryption enabled (key loaded from data directory)',
-      enabledGenerated: 'At-rest encryption enabled with auto-generated key',
-      notConfigured: 'At-rest encryption not configured',
-      notConfiguredDesc: 'TOTP secrets and OIDC client_secrets are stored in plaintext. Set MFA_ENCRYPTION_KEY or restart Bambuddy with a writable data directory to auto-generate one.',
-      allEncrypted: 'All MFA secrets are encrypted at rest.',
-      legacyRowsLabel: 'Legacy plaintext rows',
-      encryptedRowsLabel: 'Encrypted rows',
-      legacyRowsWarning: '{{count}} legacy plaintext row(s) detected. Re-save the OIDC provider or re-enroll the user’s authenticator app to migrate to encrypted storage.',
-      backupHint: 'The auto-generated key is stored at DATA_DIR/.mfa_encryption_key and is included in local backup ZIPs. Keep your backups secure or set MFA_ENCRYPTION_KEY explicitly.',
-      decryptionBrokenTitle: 'Encryption key missing',
-      decryptionBrokenError: '{{count}} encrypted record(s) cannot be decrypted because the encryption key is no longer available. Restore the previous MFA_ENCRYPTION_KEY or DATA_DIR/.mfa_encryption_key to recover.',
+      title: 'MFA 加密状态',
+      enabledFromEnv: '已启用静态加密(密钥来自 MFA_ENCRYPTION_KEY 环境变量)',
+      enabledFromFile: '已启用静态加密(密钥从数据目录加载)',
+      enabledGenerated: '使用自动生成的密钥启用静态加密',
+      notConfigured: '未配置静态加密',
+      notConfiguredDesc: 'TOTP 密钥和 OIDC client_secrets 以明文存储。请设置 MFA_ENCRYPTION_KEY 或使用可写数据目录重启 Bambuddy 以自动生成。',
+      allEncrypted: '所有 MFA 密钥均已静态加密。',
+      legacyRowsLabel: '旧版明文行',
+      encryptedRowsLabel: '已加密行',
+      legacyRowsWarning: '检测到 {{count}} 个旧版明文行。请重新保存 OIDC 提供商或重新注册用户的身份验证器应用,以迁移到加密存储。',
+      backupHint: '自动生成的密钥存储在 DATA_DIR/.mfa_encryption_key 中,并包含在本地备份 ZIP 中。请保护备份安全或显式设置 MFA_ENCRYPTION_KEY。',
+      decryptionBrokenTitle: '加密密钥缺失',
+      decryptionBrokenError: '无法解密 {{count}} 条加密记录,因为加密密钥不再可用。请恢复以前的 MFA_ENCRYPTION_KEY 或 DATA_DIR/.mfa_encryption_key 以恢复访问。',
       migrationErrorWarning: '{{count}} 行旧数据在启动时未能重新加密。请检查服务器日志并重启 Bambuddy 以重试。',
     },
 
@@ -2706,7 +2706,7 @@ export default {
       fillRequired: '请填写所有必填字段',
       passwordsDoNotMatch: '密码不匹配',
       passwordTooShort: '密码至少需要 6 个字符',
-      ldapProvisioned: 'Provisioned LDAP user "{{username}}"',
+      ldapProvisioned: '已创建 LDAP 用户「{{username}}」',
     },
     modal: {
       createUser: '创建用户',
@@ -2717,21 +2717,21 @@ export default {
       saveChanges: '保存更改',
       advancedAuthSubtitle: '使用高级认证',
       // Manual LDAP provisioning (#1298) — English fallbacks
-      tabsAriaLabel: 'User source',
-      localTab: 'Local',
+      tabsAriaLabel: '用户来源',
+      localTab: '本地',
       ldapTab: 'LDAP',
-      ldapSearchLabel: 'Search directory',
-      ldapSearchPlaceholder: 'Type a username, name, or email...',
-      ldapMinChars: 'Type at least 2 characters to search',
-      ldapTypeToSearch: 'Start typing to search the LDAP directory',
-      ldapSearching: 'Searching directory...',
-      ldapNoResults: 'No matching users in the directory',
-      ldapSearchError: 'Directory search failed. Check the LDAP server status.',
-      ldapAlreadyProvisioned: 'Already provisioned',
-      ldapSelectedLabel: 'Selected',
-      ldapProvision: 'Provision user',
-      ldapProvisioning: 'Provisioning...',
-      ldapErrorProvision: 'Provisioning failed. Check the LDAP server status and try again.',
+      ldapSearchLabel: '搜索目录',
+      ldapSearchPlaceholder: '输入用户名、姓名或电子邮件...',
+      ldapMinChars: '输入至少 2 个字符以搜索',
+      ldapTypeToSearch: '开始输入以搜索 LDAP 目录',
+      ldapSearching: '搜索目录中...',
+      ldapNoResults: '目录中没有匹配的用户',
+      ldapSearchError: '目录搜索失败。请检查 LDAP 服务器状态。',
+      ldapAlreadyProvisioned: '已创建',
+      ldapSelectedLabel: '已选',
+      ldapProvision: '创建用户',
+      ldapProvisioning: '创建中...',
+      ldapErrorProvision: '创建失败。请检查 LDAP 服务器状态后重试。',
     },
     form: {
       username: '用户名',
@@ -3094,7 +3094,7 @@ export default {
     noPermissionLinkFolder: '您没有链接文件夹的权限',
     noPermissionDeleteFolder: '您没有删除文件夹的权限',
     noPermissionPrint: '您没有打印的权限',
-    noPermissionSlice: 'You do not have permission to slice files',
+    noPermissionSlice: '您没有切片文件的权限',
     noPermissionAddToQueue: '您没有添加到队列的权限',
     noPermissionDownload: '您没有下载文件的权限',
     noPermissionRenameFile: '您没有重命名此文件的权限',
@@ -3389,47 +3389,47 @@ export default {
 
   // Slice (slicer-API integration via SliceModal)
   slice: {
-    title: 'Slice model',
-    action: 'Slice',
-    slicing: 'Slicing…',
-    printer: 'Printer profile',
-    process: 'Process profile',
-    filament: 'Filament profile',
-    filamentSlot: 'Filament {{index}} ({{type}})',
-    selectPreset: '— Select a preset —',
-    loadingPresets: 'Loading presets…',
-    analyzingPlateFilaments: 'Analyzing plate filaments…',
-    analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
-    previewToast: 'Analyzing {{name}} — {{elapsed}}',
-    previewWithProgress: 'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
-    notUsedByPlate: '— not used by this plate',
-    printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
-    noPresetsForSlot: 'No presets available',
-    presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
-    allPresetsRequired: 'All presets must be selected',
-    enqueuing: 'Submitting slice job…',
-    queued: 'Queued…',
-    failed: 'Slicing failed. Check the slicer sidecar logs.',
-    startedToast: 'Slicing {{name}} in the background…',
-    queuedToast: 'Queued: {{name}} — {{elapsed}}',
-    runningToast: 'Slicing {{name}} — {{elapsed}}',
-    runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
-    completedToast: 'Sliced {{name}}',
-    failedToast: 'Slicing {{name}} failed: {{detail}}',
+    title: '切片模型',
+    action: '切片',
+    slicing: '切片中…',
+    printer: '打印机配置',
+    process: '工艺配置',
+    filament: '耗材配置',
+    filamentSlot: '耗材 {{index}}({{type}})',
+    selectPreset: '— 选择预设 —',
+    loadingPresets: '加载预设中…',
+    analyzingPlateFilaments: '分析打印板耗材中…',
+    analyzingPlateFilamentsHint: '正在运行预览切片以发现此打印板使用的 AMS 插槽。之后会缓存 — 重新打开是即时的。',
+    previewToast: '分析 {{name}} — {{elapsed}}',
+    previewWithProgress: '分析 {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
+    notUsedByPlate: '— 此打印板未使用',
+    printerMismatch: '此 3MF 是为 {{source}} 切片的,但您选择了 {{target}}。切片器 CLI 无法为不同的打印机重新切片 3MF — 请在 Bambu Studio 中打开源文件,更改打印机并重新导出。',
+    noPresetsForSlot: '无可用预设',
+    presetsLoadFailed: '加载预设失败。请先打开设置 → 配置文件以导入。',
+    allPresetsRequired: '必须选择所有预设',
+    enqueuing: '提交切片任务中…',
+    queued: '已排队…',
+    failed: '切片失败。请检查切片器 sidecar 日志。',
+    startedToast: '在后台切片 {{name}}…',
+    queuedToast: '已排队:{{name}} — {{elapsed}}',
+    runningToast: '切片 {{name}} — {{elapsed}}',
+    runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
+    completedToast: '已切片 {{name}}',
+    failedToast: '切片 {{name}} 失败:{{detail}}',
     tier: {
-      local: 'Imported',
-      cloud: 'Cloud',
-      standard: 'Standard',
+      local: '已导入',
+      cloud: '云端',
+      standard: '标准',
     },
     cloud: {
-      notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
-      expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
-      unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
+      notAuthenticated: '登录 Bambu Cloud(设置 → 配置文件 → 云端)以查看云端预设。',
+      expired: 'Bambu Cloud 会话已过期 — 请重新登录以刷新云端预设。',
+      unreachable: '目前无法访问 Bambu Cloud。本地和标准预设仍可使用。',
     },
     bedType: {
-      label: 'Build plate',
-      auto: 'Auto (use process preset)',
-      coolPlate: 'Cool Plate',
+      label: '打印板',
+      auto: '自动(使用工艺预设)',
+      coolPlate: '冷板',
       coolPlateSuperTack: 'Cool Plate SuperTack',
       engineering: 'Engineering Plate',
       highTemp: 'High Temp Plate',
@@ -3481,26 +3481,26 @@ export default {
     spoolmanMixedContentFixOpenNewTab: '作为变通方案,可在新标签页中通过 HTTP 打开 Spoolman — 混合内容规则仅适用于嵌入式框架,独立标签页仍可正常使用。',
     spoolmanOpenInNewTab: '在新标签页中打开 Spoolman',
     labels: {
-      title: 'Print spool labels',
-      selectedCount: '{{count}} selected',
-      pickSpools: 'Pick which spools to print labels for:',
-      searchPlaceholder: 'Search name, brand, or #ID',
-      filterByMaterial: 'Material:',
-      allMaterials: 'All',
-      selectVisible: 'Select all visible ({{count}})',
-      deselectVisible: 'Deselect visible',
-      clearAll: 'Clear all',
-      noSpoolsToShow: 'No spools to show. Adjust your filter and try again.',
-      noMatches: 'No spools match the current search or filter.',
-      printOne: 'Print label for this spool',
-      printLabels: 'Print labels…',
-      bulkTitle: 'Pick spools to print labels for from the {{count}} currently shown',
-      noSpoolsTitle: 'No spools to label',
-      error: 'Could not generate labels: {{msg}}',
+      title: '打印线材标签',
+      selectedCount: '已选 {{count}} 项',
+      pickSpools: '选择要打印标签的线材:',
+      searchPlaceholder: '按名称、品牌或 #ID 搜索',
+      filterByMaterial: '材料:',
+      allMaterials: '全部',
+      selectVisible: '选择所有可见 ({{count}})',
+      deselectVisible: '取消选择可见',
+      clearAll: '全部清除',
+      noSpoolsToShow: '没有要显示的线材。请调整筛选条件后重试。',
+      noMatches: '没有线材符合当前搜索或筛选。',
+      printOne: '打印此线材的标签',
+      printLabels: '打印标签…',
+      bulkTitle: '从当前显示的 {{count}} 个线材中选择要打印标签的',
+      noSpoolsTitle: '没有要打标签的线材',
+      error: '无法生成标签:{{msg}}',
       templates: {
         ams: {
           label: 'AMS holder (30 × 15 mm)',
-          hint: 'Single label per page; fits the popular AMS filament label holder.',
+          hint: '每页一个标签;适用于流行的 AMS 耗材标签托架。',
         },
         box40x30: {
           label: '盒标签 (40 × 30 mm)',
@@ -3508,15 +3508,15 @@ export default {
         },
         box: {
           label: 'Box label (62 × 29 mm)',
-          hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+          hint: '每页一个标签;适配 Brother PT/QL 和 Dymo 小标签。',
         },
         averyL7160: {
           label: 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
-          hint: 'EU sheet stock; 21 labels per A4 page.',
+          hint: '欧洲规格纸张;每张 A4 页 21 个标签。',
         },
         avery5160: {
           label: 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
-          hint: 'US sheet stock; 30 labels per Letter page.',
+          hint: '美国规格纸张;每张 Letter 页 30 个标签。',
         },
       },
     },
@@ -3823,19 +3823,19 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}}:需要 {{required}}g,剩余 {{remaining}}g',
     printAnyway: '仍然打印',
     forceColorMatch: '强制颜色匹配',
-    staggerPrinterStarts: 'Stagger printer starts',
-    staggerGroupSize: 'Group size',
-    staggerInterval: 'Interval (min)',
-    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
-    staggerLastGroup: 'last group: {{count}}',
-    staggerTotal: 'total: {{minutes}} min',
+    staggerPrinterStarts: '错峰启动打印机',
+    staggerGroupSize: '组大小',
+    staggerInterval: '间隔(分钟)',
+    staggerPreview: '{{printers}} 台打印机 → {{groups}} 个 {{size}} 台的组,每 {{interval}} 分钟启动一次',
+    staggerLastGroup: '最后一组:{{count}}',
+    staggerTotal: '共 {{minutes}} 分钟',
     staggerToPrinters: '分批发送到 {{count}} 台打印机',
     gcodeInjection: '注入自动打印G-code',
   },
 
   // Backup
   backup: {
-    includesEncryptionKey: 'Local backups include the MFA encryption key file (DATA_DIR/.mfa_encryption_key) so a backup ZIP is self-contained. Treat the ZIP as sensitive — anyone with the file can decrypt the OIDC client secrets and TOTP secrets stored inside.',
+    includesEncryptionKey: '本地备份包含 MFA 加密密钥文件(DATA_DIR/.mfa_encryption_key),因此备份 ZIP 是自包含的。请将 ZIP 视为敏感文件 — 任何拥有该文件的人都可以解密内部存储的 OIDC 客户端密钥和 TOTP 密钥。',
     title: '备份与恢复',
     createBackup: '创建备份',
     restoreBackup: '恢复备份',
@@ -3873,7 +3873,7 @@ export default {
     enterNewToken: '输入新令牌以更新',
     tokenHint: '具有内容读写权限的细粒度令牌',
     branch: '分支',
-    provider: 'Git Provider',
+    provider: 'Git 提供商',
     providerGitHub: 'GitHub',
     providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
@@ -3993,27 +3993,27 @@ export default {
     close: '关闭',
 
     // Scheduled local backups (#884)
-    scheduledBackup: 'Scheduled Backups',
-    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',
-    frequency: 'Frequency',
-    backupTime: 'Time',
-    retention: 'Retention',
-    retentionDescription: 'Number of backups to keep',
-    outputPath: 'Output Path',
-    outputPathPlaceholder: 'Default: {{path}}',
-    outputPathDescription: 'Leave empty for default location',
-    runNow: 'Run Now',
-    backupFiles: 'Backup Files',
-    noScheduledBackups: 'No backups yet',
-    deleteBackup: 'Delete',
-    deleteBackupConfirm: 'Delete this backup file?',
-    backupRunning: 'Backup in progress...',
-    scheduledBackupComplete: 'Backup completed successfully',
-    scheduledBackupFailed: 'Backup failed',
-    nextBackup: 'Next backup',
-    backupSize: 'Size',
+    scheduledBackup: '定时备份',
+    scheduledBackupDescription: '按计划自动创建备份快照。输出目录可以挂载到 NAS 或外部存储。',
+    frequency: '频率',
+    backupTime: '时间',
+    retention: '保留',
+    retentionDescription: '保留备份数量',
+    outputPath: '输出路径',
+    outputPathPlaceholder: '默认:{{path}}',
+    outputPathDescription: '留空以使用默认位置',
+    runNow: '立即运行',
+    backupFiles: '备份文件',
+    noScheduledBackups: '暂无备份',
+    deleteBackup: '删除',
+    deleteBackupConfirm: '删除此备份文件?',
+    backupRunning: '备份中...',
+    scheduledBackupComplete: '备份成功完成',
+    scheduledBackupFailed: '备份失败',
+    nextBackup: '下次备份',
+    backupSize: '大小',
     utc: 'UTC',
-    defaultPathLabel: 'Default:',
+    defaultPathLabel: '默认:',
 
     // Category labels
     categories: {
@@ -4277,12 +4277,12 @@ export default {
       description: '添加到队列时自动开始打印。关闭后,打印任务等待手动派发。',
     },
     queueForceColorMatch: {
-      title: 'Force color match',
-      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
+      title: '强制颜色匹配',
+      description: '拒绝派发到没有完全相同耗材类型和颜色的打印机。默认关闭 — 不启用时,队列仅按型号匹配,可能选到颜色错误的打印机。',
     },
     tailscaleDisabled: {
       title: 'Tailscale 集成',
-      description: 'Enable to mark this VP as exposed over Tailscale. Shows the host\'s Tailscale address so you know which IP to paste into the slicer. The CA-import step is unchanged — this toggle has no effect on certificates.',
+      description: '启用以将此 VP 标记为通过 Tailscale 公开。显示主机的 Tailscale 地址,以便您知道要粘贴到切片器中的 IP。CA 导入步骤保持不变 — 此开关对证书无影响。',
     },
     setupRequired: {
       title: '需要设置',
@@ -4533,32 +4533,32 @@ export default {
     mqttEnergyHint: 'JSON路径从JSON负载中提取值。原始值请留空。\n乘数:Wh→kWh使用0.001,MWh→kWh使用1000。',
     mqttStateHint: 'JSON路径从JSON负载中提取值。原始值请留空。\nON值:表示"ON"的确切字符串。留空以自动检测(ON、true、1)。',
     // REST smart plug
-    restControl: 'Control',
-    restOnUrl: 'Turn ON URL',
-    restOffUrl: 'Turn OFF URL',
-    restOnBody: 'ON Request Body',
-    restOffBody: 'OFF Request Body',
-    restMethod: 'HTTP Method',
+    restControl: '控制',
+    restOnUrl: '开启 URL',
+    restOffUrl: '关闭 URL',
+    restOnBody: '开启请求体',
+    restOffBody: '关闭请求体',
+    restMethod: 'HTTP 方法',
     restHeaders: 'Custom Headers (JSON)',
-    restStatusUrl: 'Status URL',
-    restStatusPath: 'State JSON Path',
-    restStatusOnValue: 'ON Value',
+    restStatusUrl: '状态 URL',
+    restStatusPath: '状态 JSON 路径',
+    restStatusOnValue: '开启值',
     restPowerUrl: '功率URL',
-    restPowerPath: 'Power JSON Path',
+    restPowerPath: '功率 JSON 路径',
     restPowerMultiplier: '功率乘数',
     restEnergyUrl: '能耗URL',
-    restEnergyPath: 'Energy JSON Path',
+    restEnergyPath: '能耗 JSON 路径',
     restEnergyMultiplier: '能耗乘数',
-    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
-    restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',
-    restBodyHint: 'e.g. ON, {"state": "on"}',
-    restStatusHint: 'URL to poll for current state',
-    restPathHint: 'e.g. state or data.power.status',
+    restUrlRequired: 'REST 插座至少需要一个 URL(ON 或 OFF)',
+    restHeadersHint: '例如 {"Authorization": "Bearer your-token"}',
+    restBodyHint: '例如 ON, {"state": "on"}',
+    restStatusHint: '轮询当前状态的 URL',
+    restPathHint: '例如 state 或 data.power.status',
     restPowerUrlHint: '功率数据的独立URL(留空则使用状态URL)',
     restEnergyUrlHint: '能耗数据的独立URL(留空则使用状态URL)',
     restEnergyHint: '每个值可以使用独立的URL,或回退到状态URL。使用乘数进行单位转换(例如:0.001 将 Wh 转换为 kWh)。',
-    testConnection: 'Test Connection',
-    connectionSuccess: 'Connection successful',
+    testConnection: '测试连接',
+    connectionSuccess: '连接成功',
     noSwitchesInSwitchbar: '开关栏中没有开关',
     enableSwitchbarHint: '在设置 > 智能插座中启用"在开关栏显示"',
   },
@@ -5036,7 +5036,7 @@ export default {
   // Email Settings
   emailSettings: {
     placeholders: {
-      fromName: 'BamBuddy',
+      fromName: 'Bambuddy',
     },
   },
 
@@ -5445,8 +5445,8 @@ export default {
     actionPauseOff: '暂停并切断电源',
     pollInterval: '检查间隔(秒)',
     pollIntervalHint: '打印过程中每台打印机的检查频率。最小 5 秒,最大 120 秒。',
-    externalUrlMissing: 'External URL is not set.',
-    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',
+    externalUrlMissing: '未设置外部 URL。',
+    externalUrlHint: 'ML API 通过 URL 获取摄像头快照。请在通用设置中设置外部 URL,以便 ML API 容器可以访问 Bambuddy。',
     perPrinterTitle: '监控的打印机',
     perPrinterHint: '选择检测服务要监视哪些打印机。',
     monitorAll: '监控所有已连接的打印机',
@@ -5479,8 +5479,8 @@ export default {
     plateDefaultName: '打印板 {{n}}',
     materialCount: '{{count}} 种耗材',
     amsRequired: '需要 AMS',
-    slicedFor: 'Sliced for {{printer}}',
-    alsoCompatible: 'Also marked compatible: {{printers}}',
+    slicedFor: '为 {{printer}} 切片',
+    alsoCompatible: '还标记为兼容:{{printers}}',
     importToLibrary: '保存',
     sliceIn: '保存并在 {{slicer}} 中切片',
     disclaimer: 'MakerWorld 集成使用由社区记录的 API 接口。Bambuddy 与 MakerWorld 或 Bambu Lab 没有从属或认可关系。',
@@ -5575,11 +5575,11 @@ export default {
     ageLabel: '移动早于以下天数的文件',
     days: '天',
     includeNeverPrinted: '包括从未打印过的文件',
-    effectsTitle: 'What happens when you click Purge',
-    effect1: 'Matching files are moved to Trash — they are not deleted from disk yet.',
-    effect2: 'You can restore them from Trash at any time until the retention window expires.',
-    effect3: 'After retention, the trash sweeper permanently removes them from disk.',
-    effect4: 'Files in external (linked) folders are skipped — Bambuddy never deletes bytes it does not own.',    previewLoading: '正在检查匹配的文件数量…',
+    effectsTitle: '点击清除时会发生什么',
+    effect1: '匹配的文件移至回收站 — 尚未从磁盘删除。',
+    effect2: '在保留期到期之前,您可以随时从回收站恢复。',
+    effect3: '保留期满后,回收站清理器将它们从磁盘永久删除。',
+    effect4: '外部(链接)文件夹中的文件将被跳过 — Bambuddy 从不删除不属于自己的字节。',    previewLoading: '正在检查匹配的文件数量…',
     previewFailed: '无法预览清理结果。',
     previewSummary: '{{count}} 个文件 · {{size}} 将被移至回收站',
     andMore: '…还有 {{count}} 个',
@@ -5601,87 +5601,87 @@ export default {
     saveFailed: '无法保存自动清理设置。',
   },
   archivePurge: {
-    headerButton: 'Purge old',
-    headerTooltip: 'Bulk-delete old archives',
-    title: 'Purge old archives',
-    description: 'Clear out old print history. Each archive is aged by its most recent print completion — reprinting an archive refreshes its age, so active work is never purged.',
-    ageLabel: 'Delete archives not printed in the last',
-    days: 'days',
-    effectsTitle: 'What happens when you click Purge',
-    effect1: 'Each matching archive is permanently removed from the database.',
-    effect2: 'The 3MF, thumbnail, timelapse, source 3MF, F3D design file, and photo folder are all deleted from disk.',
-    effect3: 'There is no trash bin for archives — deletion is immediate and cannot be undone.',
-    effect4: 'Reprinting an archive refreshes its age clock, so archives you still use are safe.',
-    previewLoading: 'Checking how many archives match…',
-    previewFailed: 'Could not preview the purge.',
-    previewSummary: '{{count}} archives · {{size}} would be deleted',
-    andMore: '…and {{count}} more',
-    warning: 'This is permanent. Download or favourite anything you want to keep before continuing.',
-    confirmCta: 'Delete {{count}} archive(s)',
-    purging: 'Deleting…',
+    headerButton: '清除旧条目',
+    headerTooltip: '批量删除旧归档',
+    title: '清除旧归档',
+    description: '清除旧的打印历史。每个归档按其最近一次打印完成时间老化 — 重新打印归档会刷新其年龄,因此活跃工作永远不会被清除。',
+    ageLabel: '删除最近未打印的归档:',
+    days: '',
+    effectsTitle: '点击清除时会发生什么',
+    effect1: '每个匹配的归档将从数据库中永久删除。',
+    effect2: '3MF、缩略图、延时摄影、源 3MF、F3D 设计文件和照片文件夹将全部从磁盘删除。',
+    effect3: '归档没有回收站 — 删除是即时的,无法撤销。',
+    effect4: '重新打印归档会刷新其使用计时器,因此仍在使用的归档不会被清除。',
+    previewLoading: '检查匹配的归档数量…',
+    previewFailed: '无法预览清除。',
+    previewSummary: '将删除 {{count}} 个归档 · {{size}}',
+    andMore: '…还有 {{count}} 个',
+    warning: '此操作不可撤销。继续前请下载或收藏您想保留的内容。',
+    confirmCta: '删除 {{count}} 个归档',
+    purging: '删除中…',
     toast: {
-      success: 'Deleted {{count}} archive(s).',
-      failed: 'Could not purge archives.',
+      success: '已删除 {{count}} 个归档。',
+      failed: '无法清除归档。',
     },
   },
   archiveAutoPurge: {
-    enableLabel: 'Auto-purge old archives',
-    enableDescription: 'Once per day, permanently deletes archives that have not been printed within the threshold. Reprinting an archive resets the clock. No trash bin — deletion is immediate.',
-    ageLabel: 'Auto-delete archives not printed in the last',
-    ageDescription: 'Minimum 7 days, maximum 10 years. Based on the most recent print completion — reprinting an archive refreshes its age. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
-    days: 'days',
-    runNow: 'Purge archives now',
-    saveFailed: 'Could not save auto-purge settings.',
+    enableLabel: '自动清除旧归档',
+    enableDescription: '每天一次,永久删除阈值内未打印的归档。重新打印会重置计时器。无回收站 — 删除即时生效。',
+    ageLabel: '自动删除最近未打印的归档:',
+    ageDescription: '最少 7 天,最多 10 年。基于最近一次打印完成 — 重新打印会刷新年龄。删除归档、3MF、缩略图、延时摄影和照片。',
+    days: '',
+    runNow: '立即清除归档',
+    saveFailed: '无法保存自动清除设置。',
   },
   cameraTokens: {
-    title: 'Camera API Tokens',
-    navTitle: 'Camera API tokens',
+    title: '摄像头 API 令牌',
+    navTitle: '摄像头 API 令牌',
     description:
-      'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.',
-    loading: 'Loading…',
+      '长期令牌,用于将摄像头流嵌入 Home Assistant、Frigate、信息亭或其他需要稳定 URL 的工具。每个令牌仅限摄像头流,可随时撤销。',
+    loading: '加载中…',
     confirmRevoke: {
-      title: 'Revoke this token?',
-      body: 'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
-      cancel: 'Cancel',
-      confirm: 'Revoke',
+      title: '撤销此令牌?',
+      body: '使用「{{name}}」的任何设备将立即失去访问权限。此操作无法撤销。',
+      cancel: '取消',
+      confirm: '撤销',
     },
     create: {
-      title: 'Create new token',
-      nameLabel: 'Token name',
-      namePlaceholder: 'e.g. Home Assistant',
-      daysLabel: 'Days until expiry',
-      submit: 'Create',
+      title: '创建新令牌',
+      nameLabel: '令牌名称',
+      namePlaceholder: '例如 Home Assistant',
+      daysLabel: '过期天数',
+      submit: '创建',
       hint:
-        'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
+        '最大有效期 365 天。令牌值仅在创建时显示一次 — 请立即复制。',
     },
     created: {
-      title: 'Token created — copy it now',
+      title: '令牌已创建 — 立即复制',
       warning:
-        'This is the only time this token will be visible. After you close this dialog you can never view it again.',
-      copy: 'Copy',
-      dismiss: "I've saved it",
+        '这是此令牌唯一一次可见。关闭此对话框后您将无法再次查看。',
+      copy: '复制',
+      dismiss: '我已保存',
     },
     list: {
-      myTitle: 'My tokens',
-      allTitle: 'All users (admin view)',
-      empty: 'No tokens yet.',
-      name: 'Name',
-      owner: 'Owner',
-      prefix: 'Prefix',
-      created: 'Created',
-      expires: 'Expires',
-      lastUsed: 'Last used',
-      revoke: 'Revoke',
-      expired: 'Expired',
+      myTitle: '我的令牌',
+      allTitle: '所有用户(管理员视图)',
+      empty: '暂无令牌。',
+      name: '名称',
+      owner: '所有者',
+      prefix: '前缀',
+      created: '创建时间',
+      expires: '过期时间',
+      lastUsed: '最近使用',
+      revoke: '撤销',
+      expired: '已过期',
     },
     toast: {
-      created: 'Token created',
-      createFailed: 'Failed to create token',
-      revoked: 'Token revoked',
-      revokeFailed: 'Failed to revoke token',
-      loadFailed: 'Failed to load tokens',
-      copied: 'Copied to clipboard',
-      copyFailed: 'Copy failed — select and copy manually',
+      created: '令牌已创建',
+      createFailed: '创建令牌失败',
+      revoked: '令牌已撤销',
+      revokeFailed: '撤销令牌失败',
+      loadFailed: '加载令牌失败',
+      copied: '已复制到剪贴板',
+      copyFailed: '复制失败 — 手动选择并复制',
     },
   },
 

+ 208 - 208
frontend/src/i18n/locales/zh-TW.ts

@@ -318,10 +318,10 @@ export default {
       calibrationSaved: '校準已儲存!',
       calibrationFailed: '校準失敗',
       rfidRereadInitiated: '已發起 RFID 重新讀取',
-      loadInitiated: 'Loading filament…',
-      unloadInitiated: 'Unloading filament…',
-      failedToLoad: 'Failed to load filament',
-      failedToUnload: 'Failed to unload filament',
+      loadInitiated: '載入耗材中…',
+      unloadInitiated: '卸載耗材中…',
+      failedToLoad: '載入耗材失敗',
+      failedToUnload: '卸載耗材失敗',
     },
     // Connection status
     connection: {
@@ -347,8 +347,8 @@ export default {
     },
     // AMS load/unload (#891)
     ams: {
-      load: 'Load',
-      unload: 'Unload',
+      load: '載入',
+      unload: '卸載',
     },
     bedJog: {
       title: '移動熱床',
@@ -729,7 +729,7 @@ export default {
       removeF3d: '移除 F3D',
       download: '下載',
       copyDownloadLink: '複製下載連結',
-      qrCode: 'QR Code',
+      qrCode: 'QR ',
       viewPhotos: '檢視照片',
       viewPhotosCount: '檢視照片 ({{count}})',
       projectPage: '專案頁面',
@@ -742,7 +742,7 @@ export default {
       removeFromProject: '從專案中移除',
       loading: '載入中...',
       noProjectsAvailable: '無可用專案',
-      searchProjects: 'Search projects…',
+      searchProjects: '搜尋專案…',
       select: '選擇',
       deselect: '取消選擇',
       delete: '刪除',
@@ -792,7 +792,7 @@ export default {
       estimated: '預計:{{time}}',
       actual: '實際:{{time}}',
       accuracy: '準確度:{{percent}}%',
-      filament: '{{weight}}g',
+      filament: '{{weight}}',
       layer: '{{count}} 層',
       layers: '{{count}} 層',
       object: '{{count}} 個物件',
@@ -843,7 +843,7 @@ export default {
       deleteArchive: '刪除歸檔',
       deleteConfirm: '確定要刪除"{{name}}"嗎?此操作無法復原。',
       deleteButton: '刪除',
-      deletePurgeStats: 'Also remove this print from Quick Stats (filament, time, cost, energy)',
+      deletePurgeStats: '同時從快速統計中刪除此列印(耗材、時間、成本、能耗)',
       removeSource3mf: '移除源 3MF',
       removeSource3mfConfirm: '確定要從"{{name}}"中移除源 3MF 檔案嗎?這將刪除原始切片專案檔案。',
       removeButton: '移除',
@@ -1410,7 +1410,7 @@ export default {
       ldap: 'LDAP',
       twoFa: '雙因素認證',
       oidc: 'SSO / OIDC',
-      security: 'Security',
+      security: '安全',
     },
     spoolbuddy: {
       infoTitle: 'SpoolBuddy 裝置',
@@ -1760,14 +1760,14 @@ export default {
     manageQueueDescription: '新增和移除列印佇列中的項目',
     controlPrinter: '控制印表機',
     controlPrinterDescription: '暫停、繼續和停止列印',
-    cloudAccess: 'Allow cloud access',
-    cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
-    cloudBadge: 'Cloud',
-    updateEnergyCost: 'Update electricity price',
-    updateEnergyCostDescription: 'Allow this key to POST a new per-kWh electricity price to /settings/electricity-price. Useful for Home Assistant dynamic-tariff automations (Tibber, Octopus, etc.). This is the only settings field writable via API key.',
-    energyCostBadge: 'Energy',
-    legacyKey: 'Legacy',
-    legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
+    cloudAccess: '允許雲端存取',
+    cloudAccessDescription: '代表您讀取 Bambu Cloud 預設和耗材。需要登入 Bambu Cloud。',
+    cloudBadge: '雲端',
+    updateEnergyCost: '更新電價',
+    updateEnergyCostDescription: '允許此金鑰向 /settings/electricity-price POST 新的每千瓦時電價。適用於 Home Assistant 動態電價自動化(Tibber、Octopus 等)。這是唯一可透過 API 金鑰寫入的設定欄位。',
+    energyCostBadge: '能耗',
+    legacyKey: '舊版',
+    legacyKeyTooltip: '在按使用者所有權之前建立;需重建以使用雲端存取',
     unnamedKey: '未命名金鑰',
     lastUsed: '上次使用',
     read: '讀取',
@@ -1869,7 +1869,7 @@ export default {
     enterUsername: '輸入使用者名稱',
     password: '密碼',
     enterPassword: '輸入密碼',
-    passwordRequirements: 'At least 8 characters, with one uppercase, one lowercase, one digit, and one special character.',
+    passwordRequirements: '至少 8 個字元,包含一個大寫、一個小寫、一個數字和一個特殊字元。',
     confirmPassword: '確認密碼',
     confirmPasswordPlaceholder: '確認密碼',
     // Title tooltips
@@ -1900,28 +1900,28 @@ export default {
     embeddedOverlay: '嵌入式疊加層',
     preferredSlicer: '首選切片軟體',
     preferredSlicerDescription: '選擇要用於開啟檔案的切片軟體',
-    orcaslicerKnownIssuesWarning: 'OrcaSlicer 2.3.2 / 2.4.0-dev have known CLI bugs that block slicing many Bambu-authored 3MFs — see upstream issues #12426 (segfault on painted multi-extruder files) and #13386 (parameter-range strict-validation reject). Bambu Studio is recommended until the upstream fixes land.',
-    useSlicerApi: 'Use Slicer API',
-    useSlicerApiDescription: 'When on, "Slice" actions open the in-app slicer modal and call the slicer-API sidecar. When off (default), they hand off to the desktop slicer via URI scheme.',
-    slicerCard: 'Slicer',
+    orcaslicerKnownIssuesWarning: 'OrcaSlicer 2.3.2 / 2.4.0-dev 存在已知 CLI 錯誤,無法切片許多 Bambu 建立的 3MF — 見上游 issue #12426(繪製的多擠出機檔案 segfault)和 #13386(參數範圍嚴格驗證拒絕)。在上游修復發布之前,推薦使用 Bambu Studio。',
+    useSlicerApi: '使用切片器 API',
+    useSlicerApiDescription: '開啟時,「切片」操作開啟應用程式內切片器對話框並呼叫 slicer-API sidecar。關閉時(預設),透過 URI 方案交給桌面切片器。',
+    slicerCard: '切片器',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
-    slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
+    slicerApiUrlDescription: 'slicer-API sidecar 容器的 URL。留空以使用 SLICER_API_URL / BAMBU_STUDIO_API_URL 環境變數預設值。',
     slicerBundles: {
-      title: 'Slicer Bundles',
-      description: 'Import a Printer Preset Bundle (.bbscfg) exported from BambuStudio (File → Export → Export Preset Bundle → "Printer preset bundle"). Once imported, slice requests can pick presets from the bundle by name without re-uploading the JSON profile triplet.',
-      uploadButton: 'Upload bundle',
-      uploading: 'Uploading…',
-      loading: 'Loading bundles…',
-      empty: 'No bundles imported yet.',
-      summary: '{{processCount}} process · {{filamentCount}} filament presets',
-      delete: 'Delete',
-      uploadSuccess: 'Imported {{name}}',
-      uploadError: 'Bundle upload failed: {{message}}',
-      deleteSuccess: 'Bundle removed',
-      deleteError: 'Bundle delete failed: {{message}}',
-      confirmDeleteTitle: 'Remove this bundle?',
-      confirmDeleteMessage: 'Slice requests that reference "{{name}}" will fail until the bundle is re-imported.',
+      title: '切片器捆綁包',
+      description: '匯入從 BambuStudio 匯出的 Printer Preset Bundle (.bbscfg)(檔案 → 匯出 → 匯出預設捆綁包 → "Printer preset bundle")。匯入後,切片請求可以按名稱從捆綁包中選擇預設,無需重新上傳 JSON 設定三元組。',
+      uploadButton: '上傳捆綁包',
+      uploading: '上傳中…',
+      loading: '載入捆綁包中…',
+      empty: '尚未匯入捆綁包。',
+      summary: '{{processCount}} 個製程 · {{filamentCount}} 個耗材預設',
+      delete: '刪除',
+      uploadSuccess: '已匯入 {{name}}',
+      uploadError: '上傳捆綁包失敗:{{message}}',
+      deleteSuccess: '捆綁包已移除',
+      deleteError: '刪除捆綁包失敗:{{message}}',
+      confirmDeleteTitle: '移除此捆綁包?',
+      confirmDeleteMessage: '引用「{{name}}」的切片請求將失敗,直到捆綁包重新匯入。',
     },
     externalCameras: '外部攝影機',
     costTracking: '成本追蹤',
@@ -1974,10 +1974,10 @@ export default {
       fillRequiredFields: '請填寫所有必填欄位',
       passwordsDoNotMatch: '密碼不符',
       passwordTooShort: '密碼至少需要 8 個字元',
-      passwordNeedsUppercase: 'Password must contain at least one uppercase letter',
-      passwordNeedsLowercase: 'Password must contain at least one lowercase letter',
-      passwordNeedsDigit: 'Password must contain at least one digit',
-      passwordNeedsSpecial: 'Password must contain at least one special character',
+      passwordNeedsUppercase: '密碼必須至少包含一個大寫字母',
+      passwordNeedsLowercase: '密碼必須至少包含一個小寫字母',
+      passwordNeedsDigit: '密碼必須至少包含一個數字',
+      passwordNeedsSpecial: '密碼必須至少包含一個特殊字元',
       enterGroupName: '請輸入群組名稱',
       settingsSaved: '設定已儲存',
       noPermissionUpdate: '您沒有權限變更設定',
@@ -2138,9 +2138,9 @@ export default {
     cameraTypeRtsp: 'RTSP 流',
     cameraTypeSnapshot: 'HTTP 快照',
     cameraTypeUsb: 'USB 攝影機 (V4L2)',
-    cameraSnapshotUrl: 'Snapshot URL (optional)',
+    cameraSnapshotUrl: '快照 URL(選用)',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
-    cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, layer-timelapse frames, and plate detection. Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
+    cameraSnapshotUrlHelp: '用於通知縮圖、完成照片、層縮時攝影影格和列印板偵測的單一影格 URL。縮時攝影和列印板偵測各自需要自己的每印表機切換 — 此 URL 只是它們啟用時拉取的影像來源。留空以從上方即時串流擷取。適用於 go2rtc(/api/frame.jpeg)和具有專用快照端點的 IP 攝影機。',
     cameraRotation: '旋轉',
     test: '測試',
     connected: '已連線',
@@ -2306,8 +2306,8 @@ export default {
         autoLinkDesc: '首次登入時透過信箱匹配現有本機帳戶並自動連結。',
         emailClaim: '電子郵件聲明',
         emailClaimDesc: "用作電子郵件身份的 JWT 聲明。Azure Entra ID 請使用 'preferred_username' 或 'upn'(不發送 email_verified)。僅使用可信的聲明名稱。",
-        emailClaimPlaceholder: 'email',
-        emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
+        emailClaimPlaceholder: '電子郵件',
+        emailClaimCustomClaimAutoLinkWarning: '自訂宣告僅在值由租用戶管理時(例如 Azure Entra ID 的 upn / preferred_username)對自動關聯安全。如果您的 IdP 允許使用者自我宣告此宣告,請勿啟用自動關聯。',
         requireEmailVerified: '要求電子郵件已驗證',
         requireEmailVerifiedDesc: '僅在提供商將電子郵件聲明標記為已驗證時才接受。',
         requireEmailVerifiedWarning: '警告:將在未經驗證的情況下接受電子郵件。僅對受信任的提供商使用。',
@@ -2322,19 +2322,19 @@ export default {
 
     // TODO: translate encryption keys
     encryption: {
-      title: 'MFA Encryption Status',
-      enabledFromEnv: 'At-rest encryption enabled (key from MFA_ENCRYPTION_KEY environment variable)',
-      enabledFromFile: 'At-rest encryption enabled (key loaded from data directory)',
-      enabledGenerated: 'At-rest encryption enabled with auto-generated key',
-      notConfigured: 'At-rest encryption not configured',
-      notConfiguredDesc: 'TOTP secrets and OIDC client_secrets are stored in plaintext. Set MFA_ENCRYPTION_KEY or restart Bambuddy with a writable data directory to auto-generate one.',
-      allEncrypted: 'All MFA secrets are encrypted at rest.',
-      legacyRowsLabel: 'Legacy plaintext rows',
-      encryptedRowsLabel: 'Encrypted rows',
-      legacyRowsWarning: '{{count}} legacy plaintext row(s) detected. Re-save the OIDC provider or re-enroll the user’s authenticator app to migrate to encrypted storage.',
-      backupHint: 'The auto-generated key is stored at DATA_DIR/.mfa_encryption_key and is included in local backup ZIPs. Keep your backups secure or set MFA_ENCRYPTION_KEY explicitly.',
-      decryptionBrokenTitle: 'Encryption key missing',
-      decryptionBrokenError: '{{count}} encrypted record(s) cannot be decrypted because the encryption key is no longer available. Restore the previous MFA_ENCRYPTION_KEY or DATA_DIR/.mfa_encryption_key to recover.',
+      title: 'MFA 加密狀態',
+      enabledFromEnv: '已啟用靜態加密(金鑰來自 MFA_ENCRYPTION_KEY 環境變數)',
+      enabledFromFile: '已啟用靜態加密(金鑰從資料目錄載入)',
+      enabledGenerated: '使用自動產生的金鑰啟用靜態加密',
+      notConfigured: '未設定靜態加密',
+      notConfiguredDesc: 'TOTP 機密和 OIDC client_secrets 以明文儲存。請設定 MFA_ENCRYPTION_KEY 或使用可寫資料目錄重新啟動 Bambuddy 以自動產生。',
+      allEncrypted: '所有 MFA 機密皆已靜態加密。',
+      legacyRowsLabel: '舊版明文列',
+      encryptedRowsLabel: '已加密列',
+      legacyRowsWarning: '偵測到 {{count}} 個舊版明文列。請重新儲存 OIDC 供應商或重新註冊使用者的身份驗證器應用程式,以遷移到加密儲存。',
+      backupHint: '自動產生的金鑰儲存在 DATA_DIR/.mfa_encryption_key 中,並包含在本機備份 ZIP 中。請保護備份安全或顯式設定 MFA_ENCRYPTION_KEY。',
+      decryptionBrokenTitle: '加密金鑰遺失',
+      decryptionBrokenError: '無法解密 {{count}} 條加密記錄,因為加密金鑰不再可用。請還原先前的 MFA_ENCRYPTION_KEY 或 DATA_DIR/.mfa_encryption_key 以恢復存取。',
       migrationErrorWarning: '{{count}} 行舊資料在啟動時未能重新加密。請檢查伺服器日誌並重新啟動 Bambuddy 以重試。',
     },
 
@@ -2706,7 +2706,7 @@ export default {
       fillRequired: '請填寫所有必填欄位',
       passwordsDoNotMatch: '密碼不符',
       passwordTooShort: '密碼至少需要 6 個字元',
-      ldapProvisioned: 'Provisioned LDAP user "{{username}}"',
+      ldapProvisioned: '已建立 LDAP 使用者「{{username}}」',
     },
     modal: {
       createUser: '建立使用者',
@@ -2717,21 +2717,21 @@ export default {
       saveChanges: '儲存更改',
       advancedAuthSubtitle: '使用進階認證',
       // Manual LDAP provisioning (#1298) — English fallbacks
-      tabsAriaLabel: 'User source',
-      localTab: 'Local',
+      tabsAriaLabel: '使用者來源',
+      localTab: '本機',
       ldapTab: 'LDAP',
-      ldapSearchLabel: 'Search directory',
-      ldapSearchPlaceholder: 'Type a username, name, or email...',
-      ldapMinChars: 'Type at least 2 characters to search',
-      ldapTypeToSearch: 'Start typing to search the LDAP directory',
-      ldapSearching: 'Searching directory...',
-      ldapNoResults: 'No matching users in the directory',
-      ldapSearchError: 'Directory search failed. Check the LDAP server status.',
-      ldapAlreadyProvisioned: 'Already provisioned',
-      ldapSelectedLabel: 'Selected',
-      ldapProvision: 'Provision user',
-      ldapProvisioning: 'Provisioning...',
-      ldapErrorProvision: 'Provisioning failed. Check the LDAP server status and try again.',
+      ldapSearchLabel: '搜尋目錄',
+      ldapSearchPlaceholder: '輸入使用者名稱、姓名或電子郵件...',
+      ldapMinChars: '輸入至少 2 個字元以搜尋',
+      ldapTypeToSearch: '開始輸入以搜尋 LDAP 目錄',
+      ldapSearching: '搜尋目錄中...',
+      ldapNoResults: '目錄中沒有符合的使用者',
+      ldapSearchError: '目錄搜尋失敗。請檢查 LDAP 伺服器狀態。',
+      ldapAlreadyProvisioned: '已建立',
+      ldapSelectedLabel: '已選',
+      ldapProvision: '建立使用者',
+      ldapProvisioning: '建立中...',
+      ldapErrorProvision: '建立失敗。請檢查 LDAP 伺服器狀態後重試。',
     },
     form: {
       username: '使用者名稱',
@@ -3094,7 +3094,7 @@ export default {
     noPermissionLinkFolder: '您沒有連結資料夾的權限',
     noPermissionDeleteFolder: '您沒有刪除資料夾的權限',
     noPermissionPrint: '您沒有列印的權限',
-    noPermissionSlice: 'You do not have permission to slice files',
+    noPermissionSlice: '您沒有切片檔案的權限',
     noPermissionAddToQueue: '您沒有新增到佇列的權限',
     noPermissionDownload: '您沒有下載檔案的權限',
     noPermissionRenameFile: '您沒有重新命名此檔案的權限',
@@ -3389,47 +3389,47 @@ export default {
 
   // Slice (slicer-API integration via SliceModal)
   slice: {
-    title: 'Slice model',
-    action: 'Slice',
-    slicing: 'Slicing…',
-    printer: 'Printer profile',
-    process: 'Process profile',
-    filament: 'Filament profile',
-    filamentSlot: 'Filament {{index}} ({{type}})',
-    selectPreset: '— Select a preset —',
-    loadingPresets: 'Loading presets…',
-    analyzingPlateFilaments: 'Analyzing plate filaments…',
-    analyzingPlateFilamentsHint: 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
-    previewToast: 'Analyzing {{name}} — {{elapsed}}',
-    previewWithProgress: 'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
-    notUsedByPlate: '— not used by this plate',
-    printerMismatch: 'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
-    noPresetsForSlot: 'No presets available',
-    presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
-    allPresetsRequired: 'All presets must be selected',
-    enqueuing: 'Submitting slice job…',
-    queued: 'Queued…',
-    failed: 'Slicing failed. Check the slicer sidecar logs.',
-    startedToast: 'Slicing {{name}} in the background…',
-    queuedToast: 'Queued: {{name}} — {{elapsed}}',
-    runningToast: 'Slicing {{name}} — {{elapsed}}',
-    runningWithProgress: '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
-    completedToast: 'Sliced {{name}}',
-    failedToast: 'Slicing {{name}} failed: {{detail}}',
+    title: '切片模型',
+    action: '切片',
+    slicing: '切片中…',
+    printer: '印表機設定檔',
+    process: '製程設定檔',
+    filament: '耗材設定檔',
+    filamentSlot: '耗材 {{index}}({{type}})',
+    selectPreset: '— 選擇預設 —',
+    loadingPresets: '載入預設中…',
+    analyzingPlateFilaments: '分析列印板耗材中…',
+    analyzingPlateFilamentsHint: '正在執行預覽切片以發現此列印板使用的 AMS 插槽。之後會快取 — 重新開啟是即時的。',
+    previewToast: '分析 {{name}} — {{elapsed}}',
+    previewWithProgress: '分析 {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
+    notUsedByPlate: '— 此列印板未使用',
+    printerMismatch: '此 3MF 是為 {{source}} 切片的,但您選擇了 {{target}}。切片器 CLI 無法為不同的印表機重新切片 3MF — 請在 Bambu Studio 中開啟原始檔案,變更印表機並重新匯出。',
+    noPresetsForSlot: '無可用預設',
+    presetsLoadFailed: '載入預設失敗。請先開啟設定 → 設定檔以匯入。',
+    allPresetsRequired: '必須選擇所有預設',
+    enqueuing: '提交切片任務中…',
+    queued: '已排隊…',
+    failed: '切片失敗。請檢查切片器 sidecar 日誌。',
+    startedToast: '在背景切片 {{name}}…',
+    queuedToast: '已排隊:{{name}} — {{elapsed}}',
+    runningToast: '切片 {{name}} — {{elapsed}}',
+    runningWithProgress: '{{name}} – {{stage}} ({{percent}}%) – {{elapsed}}',
+    completedToast: '已切片 {{name}}',
+    failedToast: '切片 {{name}} 失敗:{{detail}}',
     tier: {
-      local: 'Imported',
-      cloud: 'Cloud',
-      standard: 'Standard',
+      local: '已匯入',
+      cloud: '雲端',
+      standard: '標準',
     },
     cloud: {
-      notAuthenticated: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
-      expired: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
-      unreachable: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
+      notAuthenticated: '登入 Bambu Cloud(設定 → 設定檔 → 雲端)以查看雲端預設。',
+      expired: 'Bambu Cloud 工作階段已過期 — 請重新登入以重新整理雲端預設。',
+      unreachable: '目前無法存取 Bambu Cloud。本機和標準預設仍可使用。',
     },
     bedType: {
-      label: 'Build plate',
-      auto: 'Auto (use process preset)',
-      coolPlate: 'Cool Plate',
+      label: '列印板',
+      auto: '自動(使用製程預設)',
+      coolPlate: '冷板',
       coolPlateSuperTack: 'Cool Plate SuperTack',
       engineering: 'Engineering Plate',
       highTemp: 'High Temp Plate',
@@ -3481,26 +3481,26 @@ export default {
     spoolmanMixedContentFixOpenNewTab: '作為替代方案,可在新分頁以 HTTP 開啟 Spoolman — 混合內容規則僅適用於內嵌框架,獨立分頁仍可正常運作。',
     spoolmanOpenInNewTab: '在新分頁開啟 Spoolman',
     labels: {
-      title: 'Print spool labels',
-      selectedCount: '{{count}} selected',
-      pickSpools: 'Pick which spools to print labels for:',
-      searchPlaceholder: 'Search name, brand, or #ID',
-      filterByMaterial: 'Material:',
-      allMaterials: 'All',
-      selectVisible: 'Select all visible ({{count}})',
-      deselectVisible: 'Deselect visible',
-      clearAll: 'Clear all',
-      noSpoolsToShow: 'No spools to show. Adjust your filter and try again.',
-      noMatches: 'No spools match the current search or filter.',
-      printOne: 'Print label for this spool',
-      printLabels: 'Print labels…',
-      bulkTitle: 'Pick spools to print labels for from the {{count}} currently shown',
-      noSpoolsTitle: 'No spools to label',
-      error: 'Could not generate labels: {{msg}}',
+      title: '列印線材標籤',
+      selectedCount: '已選 {{count}} 項',
+      pickSpools: '選擇要列印標籤的線材:',
+      searchPlaceholder: '按名稱、品牌或 #ID 搜尋',
+      filterByMaterial: '材料:',
+      allMaterials: '全部',
+      selectVisible: '選擇所有可見 ({{count}})',
+      deselectVisible: '取消選擇可見',
+      clearAll: '全部清除',
+      noSpoolsToShow: '沒有要顯示的線材。請調整篩選條件後重試。',
+      noMatches: '沒有線材符合目前搜尋或篩選。',
+      printOne: '列印此線材的標籤',
+      printLabels: '列印標籤…',
+      bulkTitle: '從目前顯示的 {{count}} 個線材中選擇要列印標籤的',
+      noSpoolsTitle: '沒有要貼標籤的線材',
+      error: '無法產生標籤:{{msg}}',
       templates: {
         ams: {
           label: 'AMS holder (30 × 15 mm)',
-          hint: 'Single label per page; fits the popular AMS filament label holder.',
+          hint: '每頁一個標籤;適用於熱門的 AMS 耗材標籤托架。',
         },
         box40x30: {
           label: '盒標籤 (40 × 30 mm)',
@@ -3508,15 +3508,15 @@ export default {
         },
         box: {
           label: 'Box label (62 × 29 mm)',
-          hint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
+          hint: '每頁一個標籤;適配 Brother PT/QL 和 Dymo 小標籤。',
         },
         averyL7160: {
           label: 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
-          hint: 'EU sheet stock; 21 labels per A4 page.',
+          hint: '歐洲規格紙張;每張 A4 頁 21 個標籤。',
         },
         avery5160: {
           label: 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
-          hint: 'US sheet stock; 30 labels per Letter page.',
+          hint: '美國規格紙張;每張 Letter 頁 30 個標籤。',
         },
       },
     },
@@ -3835,7 +3835,7 @@ export default {
 
   // Backup
   backup: {
-    includesEncryptionKey: 'Local backups include the MFA encryption key file (DATA_DIR/.mfa_encryption_key) so a backup ZIP is self-contained. Treat the ZIP as sensitive — anyone with the file can decrypt the OIDC client secrets and TOTP secrets stored inside.',
+    includesEncryptionKey: '本機備份包含 MFA 加密金鑰檔案(DATA_DIR/.mfa_encryption_key),因此備份 ZIP 是自包含的。請將 ZIP 視為敏感檔案 — 任何擁有該檔案的人都可以解密內部儲存的 OIDC 用戶端機密和 TOTP 機密。',
     title: '備份與恢復',
     createBackup: '建立備份',
     restoreBackup: '恢復備份',
@@ -3873,7 +3873,7 @@ export default {
     enterNewToken: '輸入新權杖以更新',
     tokenHint: '具有內容讀寫權限的細粒度權杖',
     branch: '分支',
-    provider: 'Git Provider',
+    provider: 'Git 供應商',
     providerGitHub: 'GitHub',
 	providerGitLab: 'GitLab',
 	providerGitea: 'Gitea',
@@ -4277,12 +4277,12 @@ export default {
       description: '新增到佇列時自動開始列印。關閉後,列印任務等待手動派發。',
     },
     queueForceColorMatch: {
-      title: 'Force color match',
-      description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
+      title: '強制顏色匹配',
+      description: '拒絕派發到沒有完全相同耗材類型和顏色的印表機。預設關閉 — 不啟用時,佇列僅按型號匹配,可能選到顏色錯誤的印表機。',
     },
     tailscaleDisabled: {
       title: 'Tailscale 整合',
-      description: 'Enable to mark this VP as exposed over Tailscale. Shows the host\'s Tailscale address so you know which IP to paste into the slicer. The CA-import step is unchanged — this toggle has no effect on certificates.',
+      description: '啟用以將此 VP 標記為透過 Tailscale 公開。顯示主機的 Tailscale 位址,以便您知道要貼上到切片器中的 IP。CA 匯入步驟保持不變 — 此切換對憑證無影響。',
     },
     setupRequired: {
       title: '需要設定',
@@ -5036,7 +5036,7 @@ export default {
   // Email Settings
   emailSettings: {
     placeholders: {
-      fromName: 'BamBuddy',
+      fromName: 'Bambuddy',
     },
   },
 
@@ -5479,8 +5479,8 @@ export default {
     plateDefaultName: '列印板 {{n}}',
     materialCount: '{{count}} 種耗材',
     amsRequired: '需要 AMS',
-    slicedFor: 'Sliced for {{printer}}',
-    alsoCompatible: 'Also marked compatible: {{printers}}',
+    slicedFor: '為 {{printer}} 切片',
+    alsoCompatible: '還標記為相容:{{printers}}',
     importToLibrary: '儲存',
     sliceIn: '儲存並在 {{slicer}} 中切片',
     disclaimer: 'MakerWorld 整合使用由社群記錄的 API 介面。Bambuddy 與 MakerWorld 或 Bambu Lab 無從屬或認可關係。',
@@ -5575,11 +5575,11 @@ export default {
     ageLabel: '移動早於以下天數的檔案',
     days: '天',
     includeNeverPrinted: '包括從未列印過的檔案',
-    effectsTitle: 'What happens when you click Purge',
-    effect1: 'Matching files are moved to Trash — they are not deleted from disk yet.',
-    effect2: 'You can restore them from Trash at any time until the retention window expires.',
-    effect3: 'After retention, the trash sweeper permanently removes them from disk.',
-    effect4: 'Files in external (linked) folders are skipped — Bambuddy never deletes bytes it does not own.',    previewLoading: '正在檢查符合的檔案數量…',
+    effectsTitle: '點擊清除時會發生什麼',
+    effect1: '符合的檔案移至回收筒 — 尚未從磁碟刪除。',
+    effect2: '在保留期到期之前,您可以隨時從回收筒復原。',
+    effect3: '保留期滿後,回收筒清理器將它們從磁碟永久刪除。',
+    effect4: '外部(連結)資料夾中的檔案將被略過 — Bambuddy 從不刪除不屬於自己的位元組。',    previewLoading: '正在檢查符合的檔案數量…',
     previewFailed: '無法預覽清理結果。',
     previewSummary: '{{count}} 個檔案 · {{size}} 將被移至資源回收筒',
     andMore: '…還有 {{count}} 個',
@@ -5601,87 +5601,87 @@ export default {
     saveFailed: '無法儲存自動清理設定。',
   },
   archivePurge: {
-    headerButton: 'Purge old',
-    headerTooltip: 'Bulk-delete old archives',
-    title: 'Purge old archives',
-    description: 'Clear out old print history. Each archive is aged by its most recent print completion — reprinting an archive refreshes its age, so active work is never purged.',
-    ageLabel: 'Delete archives not printed in the last',
-    days: 'days',
-    effectsTitle: 'What happens when you click Purge',
-    effect1: 'Each matching archive is permanently removed from the database.',
-    effect2: 'The 3MF, thumbnail, timelapse, source 3MF, F3D design file, and photo folder are all deleted from disk.',
-    effect3: 'There is no trash bin for archives — deletion is immediate and cannot be undone.',
-    effect4: 'Reprinting an archive refreshes its age clock, so archives you still use are safe.',
-    previewLoading: 'Checking how many archives match…',
-    previewFailed: 'Could not preview the purge.',
-    previewSummary: '{{count}} archives · {{size}} would be deleted',
-    andMore: '…and {{count}} more',
-    warning: 'This is permanent. Download or favourite anything you want to keep before continuing.',
-    confirmCta: 'Delete {{count}} archive(s)',
-    purging: 'Deleting…',
+    headerButton: '清除舊條目',
+    headerTooltip: '批次刪除舊歸檔',
+    title: '清除舊歸檔',
+    description: '清除舊的列印歷史。每個歸檔按其最近一次列印完成時間老化 — 重新列印歸檔會重新整理其年齡,因此活躍工作永遠不會被清除。',
+    ageLabel: '刪除最近未列印的歸檔:',
+    days: '',
+    effectsTitle: '點擊清除時會發生什麼',
+    effect1: '每個符合的歸檔將從資料庫中永久刪除。',
+    effect2: '3MF、縮圖、縮時攝影、原始 3MF、F3D 設計檔案和照片資料夾將全部從磁碟刪除。',
+    effect3: '歸檔沒有回收筒 — 刪除是即時的,無法復原。',
+    effect4: '重新列印歸檔會重新整理其使用計時器,因此仍在使用的歸檔不會被清除。',
+    previewLoading: '檢查符合的歸檔數量…',
+    previewFailed: '無法預覽清除。',
+    previewSummary: '將刪除 {{count}} 個歸檔 · {{size}}',
+    andMore: '…還有 {{count}} 個',
+    warning: '此操作無法復原。繼續前請下載或收藏您想保留的內容。',
+    confirmCta: '刪除 {{count}} 個歸檔',
+    purging: '刪除中…',
     toast: {
-      success: 'Deleted {{count}} archive(s).',
-      failed: 'Could not purge archives.',
+      success: '已刪除 {{count}} 個歸檔。',
+      failed: '無法清除歸檔。',
     },
   },
   archiveAutoPurge: {
-    enableLabel: 'Auto-purge old archives',
-    enableDescription: 'Once per day, permanently deletes archives that have not been printed within the threshold. Reprinting an archive resets the clock. No trash bin — deletion is immediate.',
-    ageLabel: 'Auto-delete archives not printed in the last',
-    ageDescription: 'Minimum 7 days, maximum 10 years. Based on the most recent print completion — reprinting an archive refreshes its age. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
-    days: 'days',
-    runNow: 'Purge archives now',
-    saveFailed: 'Could not save auto-purge settings.',
+    enableLabel: '自動清除舊歸檔',
+    enableDescription: '每天一次,永久刪除閾值內未列印的歸檔。重新列印會重設計時器。無回收筒 — 刪除即時生效。',
+    ageLabel: '自動刪除最近未列印的歸檔:',
+    ageDescription: '最少 7 天,最多 10 年。基於最近一次列印完成 — 重新列印會重新整理年齡。刪除歸檔、3MF、縮圖、縮時攝影和照片。',
+    days: '',
+    runNow: '立即清除歸檔',
+    saveFailed: '無法儲存自動清除設定。',
   },
   cameraTokens: {
-    title: 'Camera API Tokens',
-    navTitle: 'Camera API tokens',
+    title: '攝影機 API 權杖',
+    navTitle: '攝影機 API 權杖',
     description:
-      'Long-lived tokens for embedding the camera stream into Home Assistant, Frigate, kiosks, or any other tool that needs a stable URL. Each token is camera-stream-only and can be revoked at any time.',
-    loading: 'Loading…',
+      '長期權杖,用於將攝影機串流嵌入 Home Assistant、Frigate、資訊站或其他需要穩定 URL 的工具。每個權杖僅限攝影機串流,可隨時撤銷。',
+    loading: '載入中…',
     confirmRevoke: {
-      title: 'Revoke this token?',
-      body: 'Any device using "{{name}}" will lose access immediately. This cannot be undone.',
-      cancel: 'Cancel',
-      confirm: 'Revoke',
+      title: '撤銷此權杖?',
+      body: '使用「{{name}}」的任何裝置將立即失去存取權限。此操作無法復原。',
+      cancel: '取消',
+      confirm: '撤銷',
     },
     create: {
-      title: 'Create new token',
-      nameLabel: 'Token name',
-      namePlaceholder: 'e.g. Home Assistant',
-      daysLabel: 'Days until expiry',
-      submit: 'Create',
+      title: '建立新權杖',
+      nameLabel: '權杖名稱',
+      namePlaceholder: '例如 Home Assistant',
+      daysLabel: '到期天數',
+      submit: '建立',
       hint:
-        'Maximum lifetime is 365 days. The token value is shown only once on creation — copy it now.',
+        '最大有效期 365 天。權杖值僅在建立時顯示一次 — 請立即複製。',
     },
     created: {
-      title: 'Token created — copy it now',
+      title: '權杖已建立 — 立即複製',
       warning:
-        'This is the only time this token will be visible. After you close this dialog you can never view it again.',
-      copy: 'Copy',
-      dismiss: "I've saved it",
+        '這是此權杖唯一一次可見。關閉此對話框後您將無法再次查看。',
+      copy: '複製',
+      dismiss: '我已儲存',
     },
     list: {
-      myTitle: 'My tokens',
-      allTitle: 'All users (admin view)',
-      empty: 'No tokens yet.',
-      name: 'Name',
-      owner: 'Owner',
-      prefix: 'Prefix',
-      created: 'Created',
-      expires: 'Expires',
-      lastUsed: 'Last used',
-      revoke: 'Revoke',
-      expired: 'Expired',
+      myTitle: '我的權杖',
+      allTitle: '所有使用者(管理員視圖)',
+      empty: '尚無權杖。',
+      name: '名稱',
+      owner: '擁有者',
+      prefix: '前綴',
+      created: '建立時間',
+      expires: '到期時間',
+      lastUsed: '最近使用',
+      revoke: '撤銷',
+      expired: '已過期',
     },
     toast: {
-      created: 'Token created',
-      createFailed: 'Failed to create token',
-      revoked: 'Token revoked',
-      revokeFailed: 'Failed to revoke token',
-      loadFailed: 'Failed to load tokens',
-      copied: 'Copied to clipboard',
-      copyFailed: 'Copy failed — select and copy manually',
+      created: '權杖已建立',
+      createFailed: '建立權杖失敗',
+      revoked: '權杖已撤銷',
+      revokeFailed: '撤銷權杖失敗',
+      loadFailed: '載入權杖失敗',
+      copied: '已複製到剪貼簿',
+      copyFailed: '複製失敗 — 手動選擇並複製',
     },
   },
 

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-CRZ2F5mH.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-2Ag-0qip.js"></script>
+    <script type="module" crossorigin src="/assets/index-CRZ2F5mH.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Baw5c3Hn.css">
   </head>
   <body>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff