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.
This commit is contained in:
maziggy
2026-04-29 10:45:43 +02:00
parent a34beaa599
commit 7bc4712480
8 changed files with 344 additions and 62 deletions
+2
View File
@@ -44,6 +44,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Slice tracker no longer shows the "embedded settings used" warning toast** — `SliceJobTrackerContext` was emitting a yellow warning toast on every completed slice whose result carried `used_embedded_settings: true` (the auto-fallback path that fires when the sidecar's `--load-settings` triplet rejected the input). For 3MF inputs that fallback fires on essentially every slice in production (BambuStudio CLI segfaults silently on `--load-settings` over 3MF, even with the broader strip applied — verified end-to-end with the new sidecar stderr capture), so the toast was firing on essentially every completed slice and adding noise without a useful action. The `used_embedded_settings` flag still lands on `SliceResponse` / `SliceArchiveResponse` for tests + observability (`test_library_slice_api.py:347` continues to pin it); only the user-facing toast goes. `slice.fallbackUsedEmbedded` removed from all 8 locale files in the same change.
- **Settings page: permission-gated instead of admin-only** — the Settings sidebar entry has always been visible to any user holding `settings:read`, but the route guard required admin role, so a non-admin with `settings:read` would see the entry, click it, and get silently redirected back to the dashboard. The route guard now matches the sidebar: any user with `settings:read` can open the page, and the individual tabs / cards continue to enforce their own per-feature permissions (`users:read`, `groups:update`, `oidc:*`, etc. — many of them admin-only, some not). Group editor routes moved to permission-based guards too (`groups:create` for `/groups/new`, `groups:update` for `/groups/:id/edit`), so permission delegation works end-to-end. Admins retain full access since admins implicitly hold every permission.
- **i18n: full key parity across all 8 locales** — `en` is the reference; every other locale (`de`, `fr`, `it`, `ja`, `pt-BR`, `zh-CN`, `zh-TW`) is checked identically and any drift fails CI. Until now, the parity script at `frontend/scripts/check-i18n-parity.mjs` only enforced parity for `de` / `zh-CN` / `zh-TW` and demoted `fr` / `it` / `ja` / `pt-BR` to an "informational" tier — drift was reported but never gated. Result: 78 missing keys in fr and it, 66 in pt-BR, 54 in ja, accumulated across every release that added new en strings. Backfilled real translations (not English fallbacks) for every gap: `login.resetPassword.*` (12 keys, fr/it/ja), `printers.firmwareModal.*` extension (7 keys × 4 locales) from the firmware modal redesign, the full `settings.spoolbuddy.*` device-control admin block (~40 keys) for unregister / reboot / shutdown / update / restart confirms, the kiosk-side `spoolbuddy.settings.*` block (13 keys × 4) for backend & auth + diagnostics, and the new `virtualPrinter.archiveNameSource.*` block from this release ([#1152](https://github.com/maziggy/bambuddy/issues/1152)). The parity script itself dropped the two-tier `STRICT` / `info` machinery — every non-en locale is now treated equally — so any future feature that adds en strings without translating them everywhere fails CI uniformly. All 8 locales sit at 4492 leaves.
### Fixed
- **H2D Pro multi-plate dispatch double-/triple-fire** ([#1157](https://github.com/maziggy/bambuddy/issues/1157)) — Scheduling 3 plates of a multi-plate file to the same H2D Pro caused the scheduler to fire all three `project_file` commands within ~60 seconds, even though the printer hadn't transitioned out of `FINISH` for the first one yet. The H2D Pro can sit at `FINISH` for 80–210 s after accepting `project_file` before the `gcode_state` flips to `PREPARE`, and during that window the existing DB `busy_printers` seed (querying queue items in `printing` status) was empirically missing the in-flight item — observed in support logs as items 139/140/141 all dispatching with status='printing' yet only the third actually triggering a state transition. User-visible symptoms: layer count flapping, all queued plates showing as printing simultaneously, MQTT disconnect storms (33 in a single 5-minute window), eventual print failure. Root-cause fix is a defensive in-memory dispatch hold layer in `print_scheduler.py`: when `_start_print` succeeds we record `(printer_id, dispatched_at, pre_state, pre_subtask_id)`, and the next `check_queue` tick adds that printer to `busy_printers` until either (a) the watchdog observes a state/subtask transition (success path — release immediately past a 60 s minimum cooldown), or (b) a 180 s hard timeout expires (escape hatch for lost MQTT sessions). The minimum cooldown also prevents a spurious double-dispatch if the printer pulses through PREPARE→RUNNING→PREPARE in the first second after acceptance. The hold is purely additive — sits alongside the existing seed query and `_is_printer_idle` checks, doesn't depend on DB row visibility, doesn't depend on `on_print_complete` firing correctly. Per-printer isolation: a hold on printer A never blocks printer B. Edge cases covered by 12 new unit tests (`test_scheduler_dispatch_hold.py`): no-pre-state fallback (printer was offline at dispatch time), status-unavailable keeps hold (printer disconnected post-dispatch — don't release on missing data), idempotent release, hard-timeout self-cleanup, transition-during-cooldown still holds. The 90 s watchdog still owns the unhappy-path revert (queue item back to `pending` for retry) — this fix runs alongside it, not instead of it. All 179 existing scheduler tests still pass unchanged.
@@ -120,6 +121,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Setup: re-enabling auth could 422 on a password the form no longer needs** — after disabling authentication and re-enabling it (common when switching between local auth and LDAP, or recovering from a bad config), the setup form still sends `admin_password` in the body even though the backend route ignores it when an admin user already exists. The `SetupRequest` Pydantic schema enforced password complexity (uppercase + lowercase + digit + special char) unconditionally, so any existing password that predated the complexity rule — or a legitimate LDAP-mode placeholder — triggered `422 Value error, Password must contain at least one special character` before the route body could decide to ignore the field. Complexity validation has moved out of the schema and into the route body, scoped to the branch that actually creates a new local admin. Re-enabling auth with an existing admin (or any LDAP user) now accepts whatever the form sends; fresh first-time setup still rejects weak passwords with a clear 400. Two regression tests added in `test_auth_api.py`: weak password rejected at setup when creating the first admin, weak/placeholder password accepted when an admin already exists.
- **Queue: batch (quantity>1) double-dispatched onto the same printer** — scheduling an ASAP print with `quantity > 1` could end up with two queue items in `'printing'` status for the same printer, surfaced in the logs as `BUG: Multiple queue items in 'printing' status for printer N`. The scheduler's in-memory `busy_printers` set was seeded empty each tick and only populated after `_start_print` succeeded in the current iteration, so on the next tick (30 s later) `_is_printer_idle()` read the printer's live MQTT state — which on H2D / P1 series lags several seconds behind the print command and still reported `IDLE` / `FINISH` — and dispatched the second batch item onto the already-running printer. `check_queue()` now queries `PrintQueueItem` for `status='printing'` rows and seeds `busy_printers` with their printer IDs before iterating pending items, so any printer with an outstanding dispatched job is excluded regardless of what MQTT currently reports. Regression covered in `test_phantom_print_hardening.py` (`TestBusyPrinterSeedingFromPrintingItems`): seeding query returns printers with `'printing'` rows only, returns empty when none exist, and end-to-end `check_queue()` does not call `_start_print` for a pending item whose printer already has a `'printing'` row even when `_is_printer_idle()` is forced `True`.
- **Queue: active-item progress bar flashed 100% before dropping to 0%** — immediately after a queue item was dispatched, the per-item progress bar on the Queue page showed 100% (or whatever the prior print's final `mc_percent` was) for the few seconds between dispatch and the printer's MQTT state transitioning to `RUNNING`. Frontend `QueuePage.tsx` read `status.progress` directly from the printer's live MQTT snapshot, which carries over the last reported value from the previous print until the new one starts ticking. The progress bar, remaining time, ETA, and layer counter are now gated on `status.state` being `RUNNING` or `PAUSE`; in any other state (including `FINISH` from the prior print, `IDLE`, or `PREPARE` while heating) the bar renders at 0% with no stale ETA/layer values.
- **i18n placeholder mismatches in Japanese rendered literal `{{count}}` / `{{name}}` strings in the UI** — 27 `ja` strings had drifted from the en placeholder names: `printers.activeNozzle` used `{{side}}` while the runtime passes `nozzle`; `archives.card.layers` / `queue.addedBy` / `maintenance.days` / `groups.form.permissions` and 22 others either lost their placeholders entirely (translator dropped them) or used renamed keys (`{{count}}` → `{{username}}`). i18next can't bind a placeholder it doesn't see, so the count would silently disappear or — for `{{count}}` keys — render the raw `{{count}}` token in the UI. Backfilled all 27 to match en's placeholder set so interpolation resolves; the parity check now reports zero placeholder mismatches across all 8 locales. Same pass also fixed one `fr` mismatch (`projects.noProjectsFilteredHelp` lost `{{status}}`).
- **"Open in Slicer" fails on Windows / Linux for any filename containing spaces or special characters** ([#1059](https://github.com/maziggy/bambuddy/issues/1059)) — clicking "Open in Slicer" from the File Manager or Archives page produced one of three symptoms depending on the file: `.3mf` files opened Bambu Studio / OrcaSlicer but the app showed "Importing to Bambu Studio failed. Please download the file and open it manually" (the file on disk was 0 bytes); `.stl` files greyed the button out; `.step` couldn't be previewed at all. The protocol-handler URL emitted by `frontend/src/utils/slicer.ts` for OrcaSlicer (`orcaslicer://open?file=<URL>`) and Windows/Linux Bambu Studio (`bambustudio://open?file=<URL>`) was built by plain string concatenation with no `encodeURIComponent()` — the macOS `bambustudioopen://<URL>` branch was already encoding correctly, which is why macOS users didn't see this. A stale comment block in the file claimed the browser preserves the URL in the query string so no encoding is needed; that's true for the browser-to-OS handoff but ignores that the slicer itself calls `url_decode()` on the received query (BS `post_init()` calls `url_decode` then `split_str`; OrcaSlicer's Downloader regex-extracts then `url_decode`). Any already-percent-encoded character in the download URL — most commonly `%20` from filenames with spaces, which Bambuddy's archive paths produce naturally — decoded to a literal space and the slicer's subsequent HTTP GET came back 0 bytes or 404. All three URL forms now `encodeURIComponent()` the file URL, so the slicer sees the correctly-encoded URL after its own `url_decode`. The comment block is corrected to document the actual invariant. Regression test in `slicer.test.ts` feeds the exact issue reproduction URL (`Toothpick%20Launcher%20Print-in-Place.3mf`) and asserts `%2520` appears in the generated `orcaslicer://` href — so any future refactor that drops the encoding fails CI. Thanks to @jsapede for the double-encoding diagnosis and @AllanonBrooks and @lunaticds for the original reports.
### Security
+12 -28
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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',
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>