Procházet zdrojové kódy

chore(i18n): full parity across all 8 locales + drop two-tier check

  Backfill 276 missing translations across fr / it / ja / pt-BR so all
  8 shipped locales now match en. Most gaps come from features that
  landed with en+de+zh translations only:

    - login.resetPassword.* (12 × 3): fr, it, ja
    - printers.firmwareModal.* (7 × 4): all locales
    - settings.spoolbuddy.* (~40 × 3, plus 18 in ja): admin device-
      control block (unregister / reboot / shutdown / update /
      restart confirms)
    - spoolbuddy.settings.* (13 × 4): kiosk backend & auth + diagnostics
    - virtualPrinter.archiveNameSource.* (4 × 4): from this release

  Also fixes 27 ja and 1 fr placeholder-name mismatches that silently
  broke interpolation at runtime — e.g. printers.activeNozzle used
  {{side}} while the runtime passes nozzle, and several keys had
  {{count}} dropped entirely so the value would never render.

  Drop the STRICT / info two-tier machinery from check-i18n-parity.mjs:
  en is the reference, every other locale is checked identically,
  any drift fails CI. The previous tier was just deferred policy, no
  real distinction.

  All 8 locales now sit at 4492 leaves. Parity script, i18n test suite
  (11 tests), and full frontend build all green.
maziggy před 4 měsíci
rodič
revize
7bc4712480

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 0
CHANGELOG.md


+ 12 - 28
frontend/scripts/check-i18n-parity.mjs

@@ -161,11 +161,8 @@ export function compareLocales(locales) {
   return { failed: reports.length > 0, reports };
 }
 
-// Strict locales fail CI when they drift from en. Everything else discovered
-// in the locales directory is reported informationally — promote a locale to
-// STRICT once its drift is caught up. en is implicitly the reference.
-const STRICT = ['de', 'zh-CN', 'zh-TW'];
-
+// en is the reference locale; every other locale discovered in the locales
+// directory is checked identically and a drift in any of them fails CI.
 // Skip file IO / process.exit when imported as a library (e.g. from tests).
 const isMainModule = import.meta.url === url.pathToFileURL(process.argv[1] ?? '').href;
 if (isMainModule) {
@@ -178,46 +175,33 @@ if (isMainModule) {
     console.error(`No en.ts found in ${localesDir} — cannot run parity check without a reference locale.`);
     process.exit(1);
   }
-  const missingStrict = STRICT.filter((c) => !discovered.includes(c));
-  if (missingStrict.length) {
-    console.error(`STRICT locales declared but not found on disk: ${missingStrict.join(', ')}`);
-    process.exit(1);
-  }
   const codes = ['en', ...discovered.filter((c) => c !== 'en')];
   const locales = Object.fromEntries(
     codes.map((c) => [c, loadLocale(path.join(localesDir, `${c}.ts`))]),
   );
 
   const MAX_REPORT = 20;
-  const strictSet = new Set(STRICT);
-  const printReports = (reports, header) => {
-    if (!reports.length) return;
-    console.error(`\n${header}`);
+  const { reports } = compareLocales(locales);
+
+  if (reports.length) {
+    console.error(`\n=== Locale parity failures (en is the reference) ===`);
     for (const { label, items } of reports) {
       console.error(`\n[${label}] (${items.length})`);
       items.slice(0, MAX_REPORT).forEach((i) => console.error(`  ${i}`));
       if (items.length > MAX_REPORT) console.error(`  ... and ${items.length - MAX_REPORT} more`);
     }
-  };
-
-  // Label prefix is "${code}:" — route reports to strict vs informational.
-  const { reports } = compareLocales(locales);
-  const codeOf = (label) => label.split(':', 1)[0];
-  const strictReports = reports.filter((r) => strictSet.has(codeOf(r.label)));
-  const infoReports = reports.filter((r) => !strictSet.has(codeOf(r.label)));
-
-  printReports(strictReports, '=== STRICT locales (failures below fail CI) ===');
-  printReports(infoReports, '=== INFORMATIONAL locales (drift shown, does not fail CI) ===');
+  }
 
   console.log('\nLocale leaf counts:');
   for (const [code, map] of Object.entries(locales)) {
-    const tier = code === 'en' ? 'ref' : strictSet.has(code) ? 'strict' : 'info';
+    const tier = code === 'en' ? 'ref' : 'locale';
     console.log(`  ${code.padEnd(6)} ${String(map.size).padEnd(6)} [${tier}]`);
   }
 
-  if (strictReports.length > 0) {
-    console.error(`\n❌ i18n parity check failed (strict: ${STRICT.join(', ')}).`);
+  if (reports.length > 0) {
+    console.error(`\n❌ i18n parity check failed.`);
     process.exit(1);
   }
-  console.log(`\n✓ Strict locales in parity (en / ${STRICT.join(' / ')}).`);
+  const others = codes.filter((c) => c !== 'en');
+  console.log(`\n✓ All locales in parity with en (${others.join(' / ')}).`);
 }

+ 85 - 1
frontend/src/i18n/locales/fr.ts

@@ -573,6 +573,13 @@ export default {
       uploadFirmware: 'Téléverser le Firmware',
       uploadFailed: 'Échec du téléversement : {{error}}',
       uploadedToast: 'Firmware téléversé ! Lancez la mise à jour sur l\'écran.',
+      availableVersions: 'Versions disponibles',
+      usable: 'Utilisable',
+      unavailable: 'Indisponible',
+      installed: 'Installé',
+      newerBadge: 'plus récent',
+      olderBadge: 'plus ancien',
+      currentBadge: 'actuel',
     },
     accessCodePlaceholder: 'Laissez vide pour garder l\'actuel',
     // ROI editor
@@ -1357,6 +1364,7 @@ export default {
       ldap: 'LDAP',
       twoFa: 'Authentification 2FA',
       oidc: 'SSO / OIDC',
+      spoolbuddy: 'SpoolBuddy',
     },
     ldap: {
       title: 'Authentification LDAP',
@@ -2158,6 +2166,49 @@ export default {
       },
     },
 
+    spoolbuddy: {
+      infoTitle: 'Périphériques SpoolBuddy',
+      infoBody: 'Les bornes SpoolBuddy s\'enregistrent automatiquement via heartbeat. Désinscrivez ici un appareil qui n\'est plus utilisé ou un doublon obsolète laissé par un crash du daemon.',
+      duplicatesTitle: '{{count}} appareils enregistrés',
+      duplicatesBody: 'Seul le premier appareil enregistré est utilisé par l\'interface du kiosk. Si l\'un de ces appareils est un doublon obsolète issu d\'un crash, désinscrivez-le — un appareil en ligne se ré-enregistrera automatiquement au prochain heartbeat.',
+      empty: 'Aucun appareil SpoolBuddy enregistré pour le moment.',
+      online: 'En ligne',
+      offline: 'Hors ligne',
+      unregister: 'Désinscrire',
+      unregisterSuccess: 'Appareil désinscrit',
+      unregisterError: 'Échec de la désinscription',
+      confirmTitle: 'Désinscrire l\'appareil SpoolBuddy ?',
+      confirmBody: 'Cela retirera "{{hostname}}" ({{deviceId}}) de la base de données. Si l\'appareil est en ligne, il se ré-enregistrera automatiquement au prochain heartbeat.',
+      ipAddress: 'Adresse IP',
+      firmware: 'Firmware',
+      lastSeen: 'Dernière activité',
+      daemonUptime: 'Uptime du daemon',
+      systemUptime: 'Uptime système',
+      never: 'jamais',
+      nfc: 'NFC',
+      scale: 'Balance',
+      cpuTemp: 'Temp. CPU',
+      memory: 'Mémoire',
+      disk: 'Disque',
+      update: 'Mettre à jour',
+      updateConfirmTitle: 'Mettre à jour le daemon Spoolbuddy ?',
+      updateConfirmBody: 'Déclencher une mise à jour logicielle sur "{{hostname}}" ? Le daemon redémarrera une fois la mise à jour appliquée.',
+      restartBrowser: 'Redémarrer le navigateur',
+      restartBrowserConfirmTitle: 'Redémarrer le navigateur du kiosk ?',
+      restartBrowserConfirmBody: 'Redémarrer le navigateur du kiosk sur "{{hostname}}" ? L\'écran sera momentanément vide.',
+      restartDaemon: 'Redémarrer le daemon',
+      restartDaemonConfirmTitle: 'Redémarrer le daemon Spoolbuddy ?',
+      restartDaemonConfirmBody: 'Redémarrer le daemon Spoolbuddy sur "{{hostname}}" ? L\'appareil sera hors ligne quelques secondes.',
+      reboot: 'Redémarrer',
+      rebootConfirmTitle: 'Redémarrer l\'appareil ?',
+      rebootConfirmBody: 'Redémarrer "{{hostname}}" ? L\'appareil sera hors ligne pendant environ une minute.',
+      shutdown: 'Éteindre',
+      shutdownConfirmTitle: 'Éteindre l\'appareil ?',
+      shutdownConfirmBody: 'Éteindre "{{hostname}}" ? Vous aurez besoin d\'un accès physique pour le rallumer.',
+      commandConfirm: 'Confirmer',
+      commandQueued: 'Commande en file d\'attente',
+      commandError: 'Échec de l\'envoi de la commande',
+    },
   },
 
   // Notifications (for push notifications)
@@ -2352,6 +2403,20 @@ export default {
       invalidCode: 'Code invalide. Veuillez réessayer.',
     },
 
+    resetPassword: {
+      title: 'Définir un nouveau mot de passe',
+      subtitle: 'Saisissez et confirmez votre nouveau mot de passe ci-dessous.',
+      newPassword: 'Nouveau mot de passe',
+      newPasswordPlaceholder: 'Au moins 8 caractères',
+      confirmPassword: 'Confirmer le mot de passe',
+      confirmPasswordPlaceholder: 'Répéter le nouveau mot de passe',
+      saving: 'Enregistrement…',
+      submit: 'Définir le mot de passe',
+      backToLogin: 'Retour à la connexion',
+      passwordsDoNotMatch: 'Les mots de passe ne correspondent pas',
+      passwordTooShort: 'Le mot de passe doit contenir au moins 8 caractères',
+      resetFailed: 'Échec de la réinitialisation. Le lien a peut-être expiré.',
+    },
   },
 
   // Setup page
@@ -2940,7 +3005,7 @@ export default {
     description: 'Description',
     noProjects: 'Aucun projet',
     noProjectsFiltered: 'Aucun projet {{status}}',
-    noProjectsFilteredHelp: 'Les projets apparaîtront ici quand leur statut changera.',
+    noProjectsFilteredHelp: 'Vous n\'avez aucun projet "{{status}}". Les projets apparaîtront ici lorsque leur statut changera.',
     createFirst: 'Créez votre premier projet pour organiser vos builds.',
     createFirstButton: 'Créer votre premier projet',
     create: 'Créer',
@@ -4038,6 +4103,12 @@ export default {
       title: 'Supprimer l\'imprimante virtuelle',
       message: 'Êtes-vous sûr de vouloir supprimer "{{name}}" ? Cela arrêtera tous les services de cette imprimante.',
     },
+    archiveNameSource: {
+      title: 'Source du nom d\'archive',
+      description: 'Choisissez comment les nouvelles archives sont nommées lorsque les fichiers arrivent via l\'imprimante virtuelle. "Métadonnées" utilise le titre intégré par le slicer dans le 3MF (par défaut). "Nom de fichier" utilise le nom envoyé par Bambu Studio en FTP — pratique si vous avez renommé le job dans la boîte "envoyer à l\'imprimante".',
+      metadata: 'Métadonnées',
+      filename: 'Nom de fichier',
+    },
   },
 
   // Model Viewer
@@ -5009,6 +5080,19 @@ export default {
       updateInstructions: 'Mise à jour via SSH : exécutez le script d\'installation SpoolBuddy.',
       upToDate: 'À jour',
       includeBeta: 'Inclure les versions bêta',
+      systemConfig: 'Backend & authentification',
+      backendUrl: 'URL du backend Bambuddy',
+      apiToken: 'Jeton d\'API',
+      apiTokenPlaceholder: 'Saisir un jeton d\'API',
+      saveConfig: 'Enregistrer la configuration',
+      systemQueued: 'Configuration mise en file.',
+      systemFieldsRequired: 'L\'URL du backend est obligatoire.',
+      nfcDiagnostic: 'Diagnostic NFC',
+      scaleDiagnostic: 'Diagnostic balance',
+      readTagDiagnostic: 'Diagnostic lecture de tag',
+      testNfc: 'Tester le lecteur',
+      testScale: 'Tester la précision',
+      testReadTag: 'Lire un tag',
     },
     writeTag: {
       tabExisting: 'Bobine existante',

+ 84 - 0
frontend/src/i18n/locales/it.ts

@@ -573,6 +573,13 @@ export default {
       uploadFirmware: 'Carica Firmware',
       uploadFailed: 'Avvio caricamento fallito: {{error}}',
       uploadedToast: 'Firmware caricato! Avvia aggiornamento dal display.',
+      availableVersions: 'Versioni disponibili',
+      usable: 'Utilizzabile',
+      unavailable: 'Non disponibile',
+      installed: 'Installato',
+      newerBadge: 'più recente',
+      olderBadge: 'più vecchio',
+      currentBadge: 'attuale',
     },
     accessCodePlaceholder: 'Lascia vuoto per mantenere quello attuale',
     // ROI editor
@@ -1357,6 +1364,7 @@ export default {
       ldap: 'LDAP',
       twoFa: 'Autenticazione 2FA',
       oidc: 'SSO / OIDC',
+      spoolbuddy: 'SpoolBuddy',
     },
     ldap: {
       title: 'Autenticazione LDAP',
@@ -2157,6 +2165,49 @@ export default {
       },
     },
 
+    spoolbuddy: {
+      infoTitle: 'Dispositivi SpoolBuddy',
+      infoBody: 'I kiosk SpoolBuddy si registrano automaticamente tramite heartbeat. Annulla la registrazione qui se un dispositivo non è più in uso o se un duplicato obsoleto è rimasto dopo un crash del daemon.',
+      duplicatesTitle: '{{count}} dispositivi registrati',
+      duplicatesBody: 'Solo il primo dispositivo registrato viene utilizzato dall\'interfaccia kiosk. Se uno di questi è un duplicato obsoleto da un crash, annulla la sua registrazione — un dispositivo online si ri-registrerà al prossimo heartbeat.',
+      empty: 'Nessun dispositivo SpoolBuddy registrato.',
+      online: 'Online',
+      offline: 'Offline',
+      unregister: 'Annulla registrazione',
+      unregisterSuccess: 'Dispositivo deregistrato',
+      unregisterError: 'Deregistrazione non riuscita',
+      confirmTitle: 'Annullare la registrazione del dispositivo SpoolBuddy?',
+      confirmBody: 'Questo rimuoverà "{{hostname}}" ({{deviceId}}) dal database. Se il dispositivo è online, si ri-registrerà al prossimo heartbeat.',
+      ipAddress: 'Indirizzo IP',
+      firmware: 'Firmware',
+      lastSeen: 'Ultimo rilevamento',
+      daemonUptime: 'Uptime del daemon',
+      systemUptime: 'Uptime sistema',
+      never: 'mai',
+      nfc: 'NFC',
+      scale: 'Bilancia',
+      cpuTemp: 'Temp. CPU',
+      memory: 'Memoria',
+      disk: 'Disco',
+      update: 'Aggiorna',
+      updateConfirmTitle: 'Aggiornare il daemon Spoolbuddy?',
+      updateConfirmBody: 'Avviare un aggiornamento software su "{{hostname}}"? Il daemon si riavvierà al termine dell\'aggiornamento.',
+      restartBrowser: 'Riavvia browser',
+      restartBrowserConfirmTitle: 'Riavviare il browser del kiosk?',
+      restartBrowserConfirmBody: 'Riavviare il browser del kiosk su "{{hostname}}"? Il display si oscurerà brevemente.',
+      restartDaemon: 'Riavvia daemon',
+      restartDaemonConfirmTitle: 'Riavviare il daemon Spoolbuddy?',
+      restartDaemonConfirmBody: 'Riavviare il daemon Spoolbuddy su "{{hostname}}"? Il dispositivo sarà offline per alcuni secondi.',
+      reboot: 'Riavvia',
+      rebootConfirmTitle: 'Riavviare il dispositivo?',
+      rebootConfirmBody: 'Riavviare "{{hostname}}"? Il dispositivo sarà offline per circa un minuto.',
+      shutdown: 'Spegni',
+      shutdownConfirmTitle: 'Spegnere il dispositivo?',
+      shutdownConfirmBody: 'Spegnere "{{hostname}}"? Sarà necessario un accesso fisico per riaccenderlo.',
+      commandConfirm: 'Conferma',
+      commandQueued: 'Comando in coda',
+      commandError: 'Invio comando non riuscito',
+    },
   },
 
   // Notifications (for push notifications)
@@ -2351,6 +2402,20 @@ export default {
       invalidCode: 'Codice non valido. Riprova.',
     },
 
+    resetPassword: {
+      title: 'Imposta nuova password',
+      subtitle: 'Inserisci e conferma la nuova password qui sotto.',
+      newPassword: 'Nuova password',
+      newPasswordPlaceholder: 'Almeno 8 caratteri',
+      confirmPassword: 'Conferma password',
+      confirmPasswordPlaceholder: 'Ripeti la nuova password',
+      saving: 'Salvataggio…',
+      submit: 'Imposta nuova password',
+      backToLogin: 'Torna al login',
+      passwordsDoNotMatch: 'Le password non coincidono',
+      passwordTooShort: 'La password deve avere almeno 8 caratteri',
+      resetFailed: 'Reimpostazione non riuscita. Il link potrebbe essere scaduto.',
+    },
   },
 
   // Setup page
@@ -4037,6 +4102,12 @@ export default {
       title: 'Elimina stampante virtuale',
       message: 'Sei sicuro di voler eliminare "{{name}}"? Tutti i servizi di questa stampante verranno interrotti.',
     },
+    archiveNameSource: {
+      title: 'Origine nome archivio',
+      description: 'Scegli come vengono nominati i nuovi archivi quando i file arrivano tramite la stampante virtuale. "Metadati" utilizza il titolo incorporato nello slicer dal 3MF (default). "Nome file" utilizza il nome che Bambu Studio ha inviato via FTP — utile se hai rinominato il job nella finestra "invia a stampante".',
+      metadata: 'Metadati',
+      filename: 'Nome file',
+    },
   },
 
   // Model Viewer
@@ -5008,6 +5079,19 @@ export default {
       updateInstructions: 'Aggiorna via SSH: esegui lo script di installazione SpoolBuddy.',
       upToDate: 'Aggiornato',
       includeBeta: 'Includi versioni beta',
+      systemConfig: 'Backend & autenticazione',
+      backendUrl: 'URL backend Bambuddy',
+      apiToken: 'Token API',
+      apiTokenPlaceholder: 'Inserisci token API',
+      saveConfig: 'Salva configurazione',
+      systemQueued: 'Configurazione in coda.',
+      systemFieldsRequired: 'L\'URL del backend è obbligatorio.',
+      nfcDiagnostic: 'Diagnostica NFC',
+      scaleDiagnostic: 'Diagnostica bilancia',
+      readTagDiagnostic: 'Diagnostica lettura tag',
+      testNfc: 'Test lettore',
+      testScale: 'Test precisione',
+      testReadTag: 'Leggi tag',
     },
     writeTag: {
       tabExisting: 'Bobina esistente',

+ 85 - 27
frontend/src/i18n/locales/ja.ts

@@ -100,7 +100,7 @@ export default {
     viewInFileManager: 'ファイルマネージャーで表示',
     addedBy: '{{username}}が追加',
     prints: 'プリント',
-    more: 'もっと見る',
+    more: '+{{count}}件',
     ascending: '昇順',
     descending: '降順',
     back: '戻る',
@@ -243,7 +243,7 @@ export default {
     inserted: '挿入済み',
     notInserted: '未挿入',
     totalPrintHours: '印刷時間',
-    activeNozzle: 'アクティブ: {{side}}ノズル',
+    activeNozzle: 'アクティブ: {{nozzle}}ノズル',
     nozzleRack: 'ノズルラック',
     nozzleDocked: 'ドッキング中',
     nozzleMounted: 'マウント中',
@@ -324,7 +324,7 @@ export default {
     },
     // Queue info
     queue: {
-      inQueue: 'キュー内',
+      inQueue: 'キュー内 {{count}} 件',
       inQueue_plural: '{{count}}件がキュー内',
     },
     // Controls section
@@ -572,6 +572,13 @@ export default {
       uploadFirmware: 'ファームウェアをアップロード',
       uploadFailed: 'アップロード開始に失敗しました: {{error}}',
       uploadedToast: 'ファームウェアをアップロードしました!プリンター画面からアップデートを実行してください。',
+      availableVersions: '利用可能なバージョン',
+      usable: '利用可',
+      unavailable: '利用不可',
+      installed: 'インストール済み',
+      newerBadge: '新しい',
+      olderBadge: '古い',
+      currentBadge: '現在',
     },
     accessCodePlaceholder: 'プリンター設定から取得',
     // ROI editor
@@ -702,7 +709,7 @@ export default {
       download: 'ダウンロード',
       copyDownloadLink: 'ダウンロードリンクをコピー',
       qrCode: 'QRコード',
-      viewPhotos: '{{count}}枚の写真を表示',
+      viewPhotos: '写真を表示',
       viewPhotosCount: '写真を表示 ({{count}})',
       projectPage: 'プロジェクトページ',
       addToFavorites: 'お気に入りに追加',
@@ -739,7 +746,7 @@ export default {
     card: {
       previousPlate: '前のプレート',
       nextPlate: '次のプレート',
-      plateNumber: 'プレート {{number}}',
+      plateNumber: 'プレート {{index}}',
       moreOptions: 'その他のオプション',
       addToFavorites: 'お気に入りに追加',
       removeFromFavorites: 'お気に入りから削除',
@@ -757,13 +764,13 @@ export default {
       sourceFile: 'ソースファイルのみ - AMSマッピング不可',
       gcode: 'GCODE',
       source: 'ソース',
-      project: 'プロジェクト',
+      project: 'プロジェクト: {{name}}',
       estimated: '推定: {{time}}',
       actual: '実際: {{time}}',
       accuracy: '精度: {{percent}}%',
       filament: '{{weight}}g',
-      layer: 'レイヤー',
-      layers: 'レイヤー',
+      layer: '{{count}} レイヤー',
+      layers: '{{count}} レイヤー',
       object: '{{count}}オブジェクト',
       objects: '{{count}}オブジェクト',
       slicedFor: '{{model}}用にスライス',
@@ -789,7 +796,7 @@ export default {
     },
     modal: {
       deleteArchive: 'アーカイブを削除',
-      deleteConfirm: 'このアーカイブを削除しますか?',
+      deleteConfirm: '"{{name}}" を削除しますか?この操作は取り消せません。',
       deleteButton: '削除',
       removeSource3mf: 'ソース3MFを削除',
       removeSource3mfConfirm: '"{{name}}"からソース3MFファイルを削除してもよろしいですか?元のスライサープロジェクトファイルが削除されます。',
@@ -807,7 +814,7 @@ export default {
     },
     page: {
       title: '印刷アーカイブ',
-      printsCount: '{{count}}回印刷',
+      printsCount: '{{filtered}} / {{total}} 件のプリント',
       dropFilesHere: '.3mfファイルをここにドロップ',
       releaseToUpload: 'ドロップしてアップロード',
       only3mfSupported: '.3mfファイルのみ対応しています',
@@ -904,7 +911,7 @@ export default {
       label: 'SJF',
       tooltip: '短いジョブ優先 — スケジューラーが短い印刷を優先します',
     },
-    addedBy: '{{username}}が追加',
+    addedBy: '{{name}} が追加',
     nextInQueue: '次のキュー',
     clearPlateSuccess: 'プレートをクリアしました — 次の印刷の準備完了',
     plateNumber: 'プレート {{index}}',
@@ -1258,7 +1265,7 @@ export default {
     timeLeft: '残り{{duration}}',
     // Duration formats
     day: '1日',
-    days: '日',
+    days: '{{count}} 日',
     week: '1週間',
     weeks: '{{count}}週間',
     month: '1ヶ月',
@@ -1382,6 +1389,24 @@ export default {
       cpuTemp: 'CPU 温度',
       memory: 'メモリ',
       disk: 'ディスク',
+      update: 'アップデート',
+      updateConfirmTitle: 'Spoolbuddyデーモンを更新しますか?',
+      updateConfirmBody: '"{{hostname}}"のソフトウェア更新を実行しますか?更新後にデーモンが再起動されます。',
+      restartBrowser: 'ブラウザを再起動',
+      restartBrowserConfirmTitle: 'キオスクブラウザを再起動しますか?',
+      restartBrowserConfirmBody: '"{{hostname}}"のキオスクブラウザを再起動しますか?画面が一時的に消えます。',
+      restartDaemon: 'デーモンを再起動',
+      restartDaemonConfirmTitle: 'Spoolbuddyデーモンを再起動しますか?',
+      restartDaemonConfirmBody: '"{{hostname}}"のSpoolbuddyデーモンを再起動しますか?デバイスが数秒間オフラインになります。',
+      reboot: '再起動',
+      rebootConfirmTitle: 'デバイスを再起動しますか?',
+      rebootConfirmBody: '"{{hostname}}"を再起動しますか?デバイスは約1分間オフラインになります。',
+      shutdown: 'シャットダウン',
+      shutdownConfirmTitle: 'デバイスをシャットダウンしますか?',
+      shutdownConfirmBody: '"{{hostname}}"をシャットダウンしますか?再起動には物理的なアクセスが必要です。',
+      commandConfirm: '確認',
+      commandQueued: 'コマンドをキューに追加しました',
+      commandError: 'コマンドの送信に失敗しました',
     },
     ldap: {
       title: 'LDAP認証',
@@ -2292,7 +2317,7 @@ export default {
     filterPlaceholder: 'ファイルを検索...',
     deleteButton: '削除',
     deleteFiles: '{{count}}件のファイルを削除',
-    deleteFileConfirm: 'このファイルを削除しますか?',
+    deleteFileConfirm: '"{{name}}" を削除しますか?この操作は取り消せません。',
     deleteFilesConfirm: '選択した{{count}}件のファイルを削除しますか?元に戻せません。',
     noFiles: 'このディレクトリにファイルがありません',
     loadingFiles: 'ファイルを読み込み中...',
@@ -2390,6 +2415,20 @@ export default {
       invalidCode: '無効なコードです。もう一度お試しください。',
     },
 
+    resetPassword: {
+      title: '新しいパスワードを設定',
+      subtitle: '下のフィールドに新しいパスワードを入力して確認してください。',
+      newPassword: '新しいパスワード',
+      newPasswordPlaceholder: '8文字以上',
+      confirmPassword: 'パスワードの確認',
+      confirmPasswordPlaceholder: '新しいパスワードを再入力',
+      saving: '保存中…',
+      submit: '新しいパスワードを設定',
+      backToLogin: 'ログインに戻る',
+      passwordsDoNotMatch: 'パスワードが一致しません',
+      passwordTooShort: 'パスワードは8文字以上で入力してください',
+      resetFailed: 'パスワードのリセットに失敗しました。リンクが期限切れの可能性があります。',
+    },
   },
 
   // Setup page
@@ -2504,7 +2543,7 @@ export default {
       systemGroupWarning: 'システムグループ名は変更できません',
       description: '説明',
       descriptionPlaceholder: '説明を入力(任意)',
-      permissions: '権限',
+      permissions: '権限 ({{count}} 選択中)',
     },
     deleteModal: {
       title: 'グループを削除',
@@ -2592,7 +2631,7 @@ export default {
     invalidPrinterId: '無効なプリンターID',
     cameraStream: 'カメラストリーム',
     progress: '進捗',
-    eta: '予想時間 {{minutes}} 分',
+    eta: '残り時間',
     printerIdle: 'プリンター待機中',
     printerOffline: 'プリンターオフライン',
     status: {
@@ -2760,7 +2799,7 @@ export default {
       compareNow: '比較を実行',
       // Status row
       lastSynced: '最終同期:',
-      showingCount: '{{total}}件中{{shown}}件を表示',
+      showingCount: '{{total}} 件中 {{showing}} のプリセットを表示',
       noPresetsFound: 'プリセットが見つかりません',
       // Column headers
       columns: {
@@ -2796,7 +2835,7 @@ export default {
       typeFilter: 'タイプ:',
       deleteTitle: 'テンプレートを削除',
       deleteWarning: 'この操作は元に戻せません',
-      deleteConfirm: 'このプロファイルを削除しますか?',
+      deleteConfirm: '"{{name}}" を削除しますか?',
       namePlaceholder: 'テンプレート名',
       descriptionPlaceholder: '説明',
       settingsJson: '設定 (JSON)',
@@ -2862,7 +2901,7 @@ export default {
     threemfExtractionInfo: 'プリンターモデル、素材、色、印刷設定は3MFファイルから自動的に抽出されます。',
     willBeExtracted: '• 展開予定',
     filesExtracted: '• {{count}}個のファイルを展開済み',
-    uploadComplete: 'アップロード完了: {{count}}個成功',
+    uploadComplete: 'アップロード完了: {{succeeded}} 件成功',
     uploadFailed: 'アップロード失敗',
     zipFilesFailed: '{{count}}個のファイルが失敗',
     uploading: 'アップロード中...',
@@ -2974,7 +3013,7 @@ export default {
     newProject: '新規プロジェクト',
     editProject: 'プロジェクトを編集',
     deleteProject: 'プロジェクトを削除',
-    projectName: 'プロジェクト: {{name}}',
+    projectName: 'プロジェクト',
     description: '説明',
     noProjects: 'プロジェクトはまだありません',
     noProjectsFiltered: '{{status}}のプロジェクトはありません',
@@ -3087,14 +3126,14 @@ export default {
       partsProgress: 'パーツ進捗',
       printJobs: '印刷ジョブ',
       parts: 'パーツ',
-      percentComplete: '% 完了',
-      remaining: '残り',
+      percentComplete: '{{percent}}% 完了',
+      remaining: '残り {{count}} 件',
     },
     stats: {
       printJobs: '印刷ジョブ',
       total: '合計',
-      failed: '失敗',
-      partsPrinted: '印刷済みパーツ',
+      failed: '失敗 {{count}} 件',
+      partsPrinted: '印刷済みパーツ {{count}} 件',
       printTime: '印刷時間',
       filamentUsed: 'フィラメント使用量',
     },
@@ -3159,8 +3198,8 @@ export default {
     queue: {
       title: '印刷キュー',
       viewAll: 'すべて表示',
-      printing: '印刷中',
-      queued: 'キューに追加',
+      printing: '印刷中 {{count}} 件',
+      queued: 'キュー内 {{count}} 件',
     },
     prints: {
       title: '印刷 ({{count}})',
@@ -3595,7 +3634,7 @@ export default {
     includeProfiles: 'プロファイルを含む',
     backupSuccess: 'バックアップを作成しました',
     restoreSuccess: 'バックアップを復元しました',
-    backupFailed: 'バックアップに失敗しました: {{error}}',
+    backupFailed: 'バックアップに失敗しました',
     restoreFailed: '復元に失敗しました',
     restoreNote: '復元中、仮想プリンターは停止されます',
 
@@ -3931,7 +3970,7 @@ export default {
       profileDeleted: 'Kプロファイルを削除しました',
       profilesDeleted: '{{count}}件のプロファイルを削除しました',
       exportedProfiles: '{{count}}件のプロファイルをエクスポートしました',
-      importedProfiles: '{{total}}件中{{imported}}件のプロファイルをインポートしました',
+      importedProfiles: '{{total}} 件中 {{count}} 件のプロファイルをインポートしました',
       noProfilesToExport: 'エクスポートするプロファイルがありません',
       invalidFileFormat: '無効なファイル形式',
       failedToParseImport: 'インポートファイルの解析に失敗しました',
@@ -4076,6 +4115,12 @@ export default {
       title: '仮想プリンターを削除',
       message: '「{{name}}」を削除してもよろしいですか?このプリンターのすべてのサービスが停止されます。',
     },
+    archiveNameSource: {
+      title: 'アーカイブ名のソース',
+      description: '仮想プリンター経由でファイルが到着したときに、新しいアーカイブをどのように命名するかを選択します。「メタデータ」は3MFに埋め込まれたスライサータイトル(デフォルト)を使用します。「ファイル名」はBambu StudioがFTPで送信したファイル名を使用します — 「プリンターに送信」ダイアログでジョブ名を変更した場合に便利です。',
+      metadata: 'メタデータ',
+      filename: 'ファイル名',
+    },
   },
 
   // Model Viewer
@@ -5047,6 +5092,19 @@ export default {
       updateInstructions: 'SSH経由で更新:SpoolBuddyインストールスクリプトを実行してください。',
       upToDate: '最新です',
       includeBeta: 'ベータ版を含む',
+      systemConfig: 'バックエンドと認証',
+      backendUrl: 'BambuddyバックエンドURL',
+      apiToken: 'APIトークン',
+      apiTokenPlaceholder: 'APIトークンを入力',
+      saveConfig: '設定を保存',
+      systemQueued: '設定をキューに追加しました。',
+      systemFieldsRequired: 'バックエンドURLは必須です。',
+      nfcDiagnostic: 'NFC診断',
+      scaleDiagnostic: 'スケール診断',
+      readTagDiagnostic: 'タグ読み取り診断',
+      testNfc: 'リーダーをテスト',
+      testScale: '精度をテスト',
+      testReadTag: 'タグを読み取り',
     },
     writeTag: {
       tabExisting: '既存のスプール',

+ 70 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -573,6 +573,13 @@ export default {
       uploadFirmware: 'Enviar Firmware',
       uploadFailed: 'Falha ao iniciar o envio: {{error}}',
       uploadedToast: 'Firmware enviado! Inicie a atualização na tela da impressora.',
+      availableVersions: 'Versões disponíveis',
+      usable: 'Utilizável',
+      unavailable: 'Indisponível',
+      installed: 'Instalada',
+      newerBadge: 'mais recente',
+      olderBadge: 'mais antiga',
+      currentBadge: 'atual',
     },
     accessCodePlaceholder: 'Deixe vazio para manter o atual',
     // ROI editor
@@ -1357,6 +1364,7 @@ export default {
       ldap: 'LDAP',
       twoFa: 'Autenticação 2FA',
       oidc: 'SSO / OIDC',
+      spoolbuddy: 'SpoolBuddy',
     },
     ldap: {
       title: 'Autenticação LDAP',
@@ -2157,6 +2165,49 @@ export default {
       },
     },
 
+    spoolbuddy: {
+      infoTitle: 'Dispositivos SpoolBuddy',
+      infoBody: 'Os kiosks SpoolBuddy se registram automaticamente via heartbeat. Cancele o registro de um dispositivo aqui se não estiver mais em uso ou se um duplicado obsoleto foi deixado por uma falha do daemon.',
+      duplicatesTitle: '{{count}} dispositivos registrados',
+      duplicatesBody: 'Apenas o primeiro dispositivo registrado é usado pela interface do kiosk. Se algum destes for um duplicado obsoleto após uma falha, cancele seu registro — um dispositivo online se registrará novamente no próximo heartbeat.',
+      empty: 'Nenhum dispositivo SpoolBuddy registrado ainda.',
+      online: 'Online',
+      offline: 'Offline',
+      unregister: 'Cancelar registro',
+      unregisterSuccess: 'Dispositivo removido',
+      unregisterError: 'Falha ao cancelar registro',
+      confirmTitle: 'Cancelar registro do dispositivo SpoolBuddy?',
+      confirmBody: 'Isso removerá "{{hostname}}" ({{deviceId}}) do banco de dados. Se o dispositivo estiver online, ele se registrará novamente no próximo heartbeat.',
+      ipAddress: 'Endereço IP',
+      firmware: 'Firmware',
+      lastSeen: 'Visto pela última vez',
+      daemonUptime: 'Tempo ativo do daemon',
+      systemUptime: 'Tempo ativo do sistema',
+      never: 'nunca',
+      nfc: 'NFC',
+      scale: 'Balança',
+      cpuTemp: 'Temp. CPU',
+      memory: 'Memória',
+      disk: 'Disco',
+      update: 'Atualizar',
+      updateConfirmTitle: 'Atualizar daemon do Spoolbuddy?',
+      updateConfirmBody: 'Iniciar uma atualização de software em "{{hostname}}"? O daemon será reiniciado após a aplicação.',
+      restartBrowser: 'Reiniciar navegador',
+      restartBrowserConfirmTitle: 'Reiniciar o navegador do kiosk?',
+      restartBrowserConfirmBody: 'Reiniciar o navegador do kiosk em "{{hostname}}"? A tela ficará em branco brevemente.',
+      restartDaemon: 'Reiniciar daemon',
+      restartDaemonConfirmTitle: 'Reiniciar daemon do Spoolbuddy?',
+      restartDaemonConfirmBody: 'Reiniciar o daemon do Spoolbuddy em "{{hostname}}"? O dispositivo ficará offline por alguns segundos.',
+      reboot: 'Reiniciar',
+      rebootConfirmTitle: 'Reiniciar o dispositivo?',
+      rebootConfirmBody: 'Reiniciar "{{hostname}}"? O dispositivo ficará offline por cerca de um minuto.',
+      shutdown: 'Desligar',
+      shutdownConfirmTitle: 'Desligar o dispositivo?',
+      shutdownConfirmBody: 'Desligar "{{hostname}}"? Será necessário acesso físico para ligá-lo novamente.',
+      commandConfirm: 'Confirmar',
+      commandQueued: 'Comando enfileirado',
+      commandError: 'Falha ao enviar comando',
+    },
   },
 
   // Notifications (for push notifications)
@@ -4051,6 +4102,12 @@ export default {
       title: 'Excluir Impressora Virtual',
       message: 'Tem certeza que deseja excluir "{{name}}"? Isso irá parar todos os serviços desta impressora.',
     },
+    archiveNameSource: {
+      title: 'Origem do nome do arquivo',
+      description: 'Escolha como os novos arquivos são nomeados quando chegam pela impressora virtual. "Metadados" usa o título embutido pelo slicer no 3MF (padrão). "Nome do arquivo" usa o nome que o Bambu Studio enviou via FTP — útil se você renomeou o job no diálogo "enviar para impressora".',
+      metadata: 'Metadados',
+      filename: 'Nome do arquivo',
+    },
   },
 
   // Model Viewer
@@ -5022,6 +5079,19 @@ export default {
       updateInstructions: 'Atualize via SSH: execute o script de instalação do SpoolBuddy.',
       upToDate: 'Atualizado',
       includeBeta: 'Incluir versões beta',
+      systemConfig: 'Backend e autenticação',
+      backendUrl: 'URL do backend Bambuddy',
+      apiToken: 'Token de API',
+      apiTokenPlaceholder: 'Insira o token de API',
+      saveConfig: 'Salvar configuração',
+      systemQueued: 'Configuração enfileirada.',
+      systemFieldsRequired: 'URL do backend é obrigatória.',
+      nfcDiagnostic: 'Diagnóstico NFC',
+      scaleDiagnostic: 'Diagnóstico da balança',
+      readTagDiagnostic: 'Diagnóstico de leitura de tag',
+      testNfc: 'Testar leitor',
+      testScale: 'Testar precisão',
+      testReadTag: 'Ler tag',
     },
     writeTag: {
       tabExisting: 'Bobina existente',

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-CXX0Eh7g.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-DE-w2t-x.js"></script>
+    <script type="module" crossorigin src="/assets/index-CXX0Eh7g.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-7GmlJb0k.css">
   </head>
   <body>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů