diff --git a/web/locales/cs/main.ftl b/web/locales/cs/main.ftl index 3cf678eb..de6bd1c2 100644 --- a/web/locales/cs/main.ftl +++ b/web/locales/cs/main.ftl @@ -1 +1,803 @@ -# Czech translations. Missing messages fall back to English. +# ============================================================================ +# Czech (cs) locale. Keys must stay identical to locales/en/main.ftl. +# Only the values (right of "=") are translated. +# ============================================================================ + +# --- Společná / sdílená slova ------------------------------------------------ +common-cancel = Zrušit +common-no = Ne +common-remove = Odebrat +common-yes = Ano + +# --- Chrome stránky (ui/, navbar) -------------------------------------------- +ui-back-to-admin = Zpět do administračního panelu +ui-back-to-admin-panel = ← Administrační panel +ui-back-to-form = Zpět do formuláře +ui-dismiss-alert = zavřít upozornění +notice-log-in-first = Nejprve se prosím přihlaste. + +nav-admin = Administrace +nav-permissions = Oprávnění +nav-analytics = Analytika +nav-rates = Kurzy +nav-import = Import +nav-export = Export +nav-password = Heslo +nav-log-out = Odhlásit se +nav-login = Přihlášení +nav-register = Registrace +nav-main-menu = hlavní menu +nav-mobile-menu = mobilní menu + +# --- Názvy polí používané ve složených zprávách ------------------------------ +label-profile = profil +label-table = tabulka +label-column = sloupec +label-set-name = název sady +label-rule-name = název pravidla +label-validation-set-name = název validační sady +label-minimum = Minimum +label-maximum = Maximum +label-warning-threshold = Hranice upozornění +label-precision = Přesnost +label-scale = Desetinná místa +label-column-name = Název sloupce +label-table-name = Název tabulky +label-linked-table = Odkazovaná tabulka +label-column-alias = Alias sloupce +label-profile-name = Název profilu + +# --- Odznaky sloupců ---------------------------------------------------------- +column-flag-system = systémový +column-flag-primary-key = primární klíč +column-flag-nullable = může být prázdný +column-flag-required = povinný +column-flag-quantity-ledger = kvantitativní evidence +column-flag-half-up = zaokrouhleno nahoru +column-flag-read-only = pouze pro čtení +column-flag-generated = generovaný +column-flag-generated-from = generováno z { $source } + +# --- Přihlášení / registrace / změna hesla ----------------------------------- +login-title = Přihlášení +login-username-or-email = Uživatelské jméno nebo e-mail +login-password = Heslo +login-optional = (volitelné) +login-button-login = Přihlásit se +login-no-account = Ještě nemáte účet? +login-error-title = Přihlášení se nezdařilo +login-error-identifier-required = Uživatelské jméno nebo e-mail je povinné +login-error-invalid-token = Server vrátil neplatný přístupový token + +register-title = Registrace +register-button-register = Registrovat se +register-confirm-password = Potvrdit heslo +register-have-account = Už máte účet? +register-error-title = Registrace se nezdařila +register-success-title = Registrace úspěšná. Nyní se můžete přihlásit. +register-success-message = + ID uživatele: { $id } + Uživatelské jméno: { $username } + E-mail: { $email } + Role: { $role } + Časové pásmo: { $timezone } + Země telefonu: { $phone_country } + +field-username = Uživatelské jméno +field-email = E-mail +field-timezone = Časové pásmo +field-phone-country = Země telefonu +field-current-password = Aktuální heslo +field-new-password = Nové heslo +field-confirm-new-password = Potvrdit nové heslo + +password-title = Změna hesla +password-button-change = Změnit heslo +password-empty-current-hint = U čerstvě vytvořeného účtu správce nebo super správce nechte aktuální heslo prázdné. +password-error-title = Změnu hesla se nepodařilo provést +password-success-title = Heslo změněno +password-changed-message = Vaše nové heslo je aktivní. + +# --- Administrační panel (prohlížeč pracovní plochy) -------------------------- +admin-title = Administrační panel +admin-eyebrow-workspace = Pracovní plocha +admin-description = Procházejte profily, tabulky a jejich fyzické sloupce. Vyberte tabulku a zobrazí se možnosti její úpravy. +admin-add-table = Přidat tabulku +admin-add-logic = Přidat logiku +admin-add-validation = Přidat validaci +admin-add-rule = Přidat pravidlo +admin-export = Export +admin-exchange-rates = Devizové kurzy +admin-unavailable-title = Administrační panel není dostupný +admin-forbidden = Tento účet nemá oprávnění otevřít administrační panel. + +admin-profiles = Profily +admin-no-profiles-available = Nejsou k dispozici žádné profily. +admin-table-count = { $count -> + [one] { $count } tabulka + [few] { $count } tabulky + *[other] { $count } tabulek +} +admin-shared-by-every-profile = sdílená všemi profily +admin-tables-heading = Tabulky +admin-select-profile-first = Vyberte profil, abyste viděli jeho tabulky. +admin-no-global-tables = Neexistují žádné globální tabulky. +admin-no-profile-tables = Tento profil nemá vlastní tabulky. +admin-no-dependencies = Bez závislostí +admin-depends-on-prefix = Závisí na +admin-row-display-prefix = zobrazení: +admin-add-rename-columns = Přidat / přejmenovat sloupce +admin-delete-table = Smazat tabulku +admin-who-may-use-it = Kdo ji může používat +admin-pick-table-hint = Vyberte tabulku, abyste do ní přidali sloupce, smazali ji nebo určili, kdo ji může používat. +admin-new-global-table = Nová globální tabulka +admin-new-table = Nová tabulka +admin-from-template = Ze šablony +admin-copy-profile = Kopírovat profil +admin-rename-history = Historie přejmenování +admin-columns = Sloupce +admin-column-counts = { $user -> + [one] { $user } uživatel + [few] { $user } uživatelé + *[other] { $user } uživatelů +} · { $system -> + [one] { $system } systém + [few] { $system } systémy + *[other] { $system } systémů +} +admin-select-table-inspect = Vyberte tabulku, abyste zobrazili její sloupce. +admin-no-visible-columns = Tato tabulka nemá žádné viditelné sloupce. +admin-system-columns-label = Systémové sloupce · skryté před uživateli + +# --- Stránky definice tabulky (sloupce / smazat / kopírovat / šablona / historie) +td-columns-title = Sloupce +td-add-columns-to = Přidat sloupce do +td-append-hint = Sloupce se přidávají na konec. Nic, co už existuje, se nemění a nové sloupce lze indexovat hned při přidání. +td-rename-column = Přejmenovat sloupec +td-rename-hint = Přejmenovává se jen zobrazený název, ne fyzický sloupec pod ním, takže uložená data a skripty zůstávají nedotčené. Možné jen dokud tabulka nemá žádné řádky. +td-column-label = Sloupec +td-choose-column = Vyberte sloupec +td-new-name = Nový název +td-rename-button = Přejmenovat sloupec +td-columns-now = Současné sloupce +td-col-column = Sloupec +td-col-type = Typ +td-col-notes = Poznámky +td-scripts = Skripty +td-col-target-column = Cílový sloupec +td-col-description = Popis +td-source = zdroj + +td-could-not-add-column = Sloupec se nepodařilo přidat +td-staged = Připraveno +td-link-alias = Alias odkazu +td-column-name = Název sloupce +td-link-alias-hint = Alias je název sloupce, kterým se tento vztah rozlišuje. +td-column-type = Typ sloupce +td-choose-type = Vyberte typ +td-fk-link = FKlink +td-temporal-type = Časový typ +td-choose-temporal-type = Vyberte časový typ +td-gtin-type = Typ GTIN +td-choose-gtin-length = Vyberte délku GTIN +td-precision = Přesnost +td-precision-hint = Celkový počet uložených číslic, alespoň 1. +td-scale = Desetinná místa +td-scale-hint = Číslice za desetinnou čárkou, nejvýše tolik jako přesnost. +td-referenced-table = Odkazovaná tabulka +td-choose-table = Vyberte tabulku +td-group-global = Globální +td-group-user = Vytvořeno uživatelem +td-group-system = Vytvořeno systémem +td-no-eligible-tables = V tomto rozsahu neexistují žádné vhodné tabulky. +td-currency = Měna +td-rounding = Zaokrouhlování +td-rounding-none = žádné +td-rounding-half-up = zaokrouhlit nahoru +td-indexing = Indexování +td-indexed-automatically = Indexováno automaticky — odkaz je cizí klíč a server indexuje každý z nich. +td-required = Povinný +td-required-hint = Řádek pro něj musí obsahovat hodnotu. +td-quantity-ledger = Kvantitativní evidence +td-ql-only = Jen { $types }. +td-shared-no-quantity-ledger = Sdílená tabulka nevede vlastní účetnictví, takže nemůže obsahovat sloupec kvantitativní evidence. +td-stage-column = Připravit sloupec +td-no-staged-columns = Zatím nejsou připravené žádné sloupce. Popište jeden výše a stiskněte +td-col-name = Název +td-col-indexed = Indexovaný +td-col-options = Možnosti +td-indexed-automatically-tag = indexováno automaticky +td-money-exact = přesně +td-money-half-up = zaokrouhleno nahoru +td-add-columns-button = { $count -> + [one] Přidat { $count } sloupec do tabulky + [few] Přidat { $count } sloupce do tabulky + *[other] Přidat { $count } sloupců do tabulky +} + +td-eyebrow-table-definition = Definice tabulky +td-columns-count = { $count -> + [one] { $count } sloupec + [few] { $count } sloupce + *[other] { $count } sloupců +} +td-identified-by = identifikována podle +td-its-id = svého id +td-depends-on-prefix = závisí na +td-acts-on-profile = Vše na této stránce se týká tohoto profilu. +td-global-label = Globální — všechny profily +td-tab-aria = akce s tabulkou +td-tab-columns = Sloupce +td-tab-columns-sub = Přidat sloupce a přejmenovat je +td-tab-delete = Smazat +td-tab-delete-sub = Odebrat tuto tabulku +td-tab-access = Přístup +td-tab-access-sub = Které role ji mohou číst a zapisovat +td-tab-copy = Kopírovat profil +td-tab-copy-sub = Duplikovat jeho strukturu +td-tab-template = Ze šablony +td-tab-template-sub = Vytvořit tabulky z Typst faktury +td-tab-history = Historie přejmenování +td-tab-history-sub = Jak sloupce získaly své názvy +td-backend-managed-title = Spravováno backendem +td-backend-managed-hint = Tato tabulka patří backendu. Její definici si můžete prohlédnout v administračním panelu, ale měnit se dá jen přes API backendu, takže se na ni nevztahuje žádná z akcí na zápis. + +td-could-not-continue = Nelze pokračovat +td-done = Hotovo +td-sql-ran = SQL, které backend provedl +td-delete-title = Smazat tabulku +td-delete-table = Smazat tabulku +td-delete-hint = Odebere tabulku i její definici, a pokud to byla poslední tabulka profilu, i profil. Backend odmítne smazat tabulku, která má stále řádky. +td-links-to = Odkazuje na { $tables }. +td-type-to-confirm = Napište +td-no-table-chosen = Nebyla vybrána žádná tabulka +td-pick-table-delete = Nejprve vyberte tabulku ke smazání v administračním panelu. +td-copy-title = Kopírovat profil +td-copy-into = Kopírovat +td-copy-hint = Kopíruje strukturu — tabulky, odkazy a skripty — bez řádků. Pokud nezaškrtnete žádnou tabulku, zkopíruje se celý profil. +td-new-profile-name = Název nového profilu +td-copy-button = Kopírovat profil +td-template-title = Vytvořit ze šablony +td-template-created-tables = Tabulky vytvořené ze šablony +td-col-table = Tabulka +td-col-collection = Kolekce +td-col-parent = Nadřazená +td-root = kořen +td-template-create-heading = Vytvořit tabulky z fakturové šablony +td-template-hint = Načte kontrakt #let komp_ac_fields = (…) z Typst šablony. Cesta, která odpovídá existující tabulce a sloupci, se stane odkazem na ni; vše ostatní se stane sloupcem TEXT, který později upravíte, a každé [] se stane podřízenou tabulkou. +td-table-name = Název tabulky +td-row-display-columns = Sloupce identifikující řádek +td-row-display-hint = Oddělte čárkami, platí jen pro kořenovou tabulku. +td-template-source = Zdroj šablony +td-template-create-button = Vytvořit ze šablony +td-history-title = Historie přejmenování +td-history-heading = Historie přejmenování sloupců +td-history-empty = V { $scope } nebyl přejmenován žádný sloupec. +td-history-empty-this-profile = V tomto profilu nebyl přejmenován žádný sloupec. +td-col-was = Byl +td-col-is = Je +td-col-when = Kdy + +td-columns-added = { $count -> + [one] { $count } sloupec přidán do `{ $table }`. + [few] { $count } sloupce přidány do `{ $table }`. + *[other] { $count } sloupců přidáno do `{ $table }`. +} +td-profile-copied = { $message } — zkopírováno { $tables -> + [one] { $tables } tabulka + [few] { $tables } tabulky + *[other] { $tables } tabulek +} a { $scripts -> + [one] { $scripts } skript + [few] { $scripts } skripty + *[other] { $scripts } skriptů +}. +td-template-tables-created = { $count -> + [one] Vytvořena { $count } tabulka ze šablony. + [few] Vytvořeny { $count } tabulky ze šablony. + *[other] Vytvořeno { $count } tabulek ze šablony. +} +td-err-select-table-first = Nejprve vyberte tabulku. +td-err-profile-gone = Profil `{ $missing }` už neexistuje. +td-err-not-a-table = `{ $missing }` není tabulka rozsahu { $scope }. +td-err-describe-column = Před přidáním popište alespoň jeden sloupec. +td-err-backend-no-columns = Backend nepřidal sloupce. +td-err-choose-rename = Vyberte sloupec a zadejte jeho nový název. +td-err-backend-no-rename = Backend nepřejmenoval sloupec. +td-err-type-name = Pro potvrzení smazání napište přesný název tabulky. +td-err-backend-no-delete = Backend nesmazal tabulku. +td-err-name-profile = Zadejte název profilu, do kterého se kopíruje. +td-err-backend-no-copy = Backend nezkopíroval profil. +td-err-template-required = Vyžaduje se název tabulky i zdroj šablony. +td-err-backend-no-template = Backend nevytvořil tabulky ze šablony. +td-err-permission = Vyžaduje se oprávnění ke správě tabulek. +td-unavailable-title = Definice tabulky není dostupná + +# --- Referenční kurzy ECB ------------------------------------------------------ +ecb-title = Devizové kurzy +ecb-heading = Referenční kurzy ECB +ecb-description = Importér načítá zveřejněné kurzy ECB jednou denně a zaznamenává každý pokus. Konverze čtou jen to, co ověřil, takže pokrytí rozhoduje o tom, zda lze cizoměnovou částku zaúčtovat vůbec. +ecb-unavailable-title = Devizové kurzy nejsou dostupné +ecb-forbidden = Pro otevření této stránky se vyžaduje čtení údajů o devizových kurzech. +ecb-import-attempts = Pokusy o import +ecb-failed-count = { $count -> + [one] { $count } neúspěšný + [few] { $count } neúspěšné + *[other] { $count } neúspěšných +} +ecb-no-attempts-hint = Importér nezaznamenal žádný pokus. Běží při startu a poté denně, takže prázdný záznam znamená, že server od vytvoření této tabulky nedokončil žádný běh. +ecb-th-result = Výsledek +ecb-th-started = Spuštěno +ecb-th-finished = Dokončeno +ecb-th-took = Trvalo +ecb-th-window = Období +ecb-th-observations = Pozorování +ecb-th-verified-through = Ověřeno do +ecb-batch-succeeded = úspěšné +ecb-batch-running = probíhá +ecb-batch-failed = neúspěšné +ecb-still-running = stále běží +ecb-audit-hint = Pokusy jsou auditní záznam: nelze je upravovat ani mazat a dávka, která znovu načetla den, který už vlastnila starší, nevloží nic — proto může být nový počet nižší než celkový. +ecb-import-running = import probíhá +ecb-postable-lead = Co lze zaúčtovat právě teď +ecb-ordinary-transactions = Běžné transakce +ecb-statements-title = Výpisy a rozhodující data +ecb-dated-up-to = datované do včetně +ecb-ordinary-rate-hint = Přepočteno kurzem zveřejněným v pracovní den před transakcí. +ecb-statements-rate-hint = Přepočteno kurzem zveřejněným v ten den, nebo v poslední pracovní den před ním. +ecb-postable-limit-hint = Cizoměnová částka datovaná po svém limitu se odmítá přímo, dokud importér nedoběhne — nepřepočítává se zastaralým kurzem. +ecb-nothing = Nic. +ecb-no-publication-hint = Nikdy nebylo ověřeno žádné zveřejnění, takže každá cizoměnová částka se odmítá bez ohledu na datum. +ecb-verified-through = Ověřeno do +ecb-never = nikdy +ecb-up-to-date = aktuální +ecb-behind-count = { $count -> + [one] { $count } zmeškaný publikační den + [few] { $count } zmeškané publikační dny + *[other] { $count } zmeškaných publikačních dní +} +ecb-no-coverage = bez pokrytí +ecb-should-reach = Má dosáhnout +ecb-last-attempt = Poslední pokus +ecb-none-recorded = žádný nezaznamenán +ecb-last-success = Poslední úspěch +ecb-none = žádný +ecb-next-import = Další import +ecb-currencies-on = Měny k { $date } +ecb-the-covered-day = pokrytý den +ecb-most-recent-failure = Poslední neúspěch — dávka { $batch }, spuštěno { $started }: +ecb-no-reason-recorded = nebyl zaznamenán žádný důvod. +ecb-headline-current = Kurzy jsou aktuální +ecb-headline-never-ran = Nikdy nebyly importovány žádné kurzy +ecb-headline-behind = Kurzy se zpožďují +ecb-explanation-current = Ověřen je každý publikační den až po nejnovější, takže konverze se zaúčtovávají normálně. +ecb-explanation-never-ran = Dokud se jeden nepodaří, každá konverze v cizí měně se odmítá pro nedostatek pokrytí. +ecb-explanation-behind = { $count -> + [one] Chybí { $count } publikační den. + [few] Chybí { $count } publikační dny. + *[other] Chybí { $count } publikačních dní. +} Konverze datovaná do mezery se odmítá, dokud importér nedoběhne. +ecb-new-of = { $inserted } nových z { $total } +ecb-in = za { $time } +ecb-ago = před { $time } +ecb-less-than-1s = <1s + +# --- Oprávnění (role / uživatelé / přístup) ------------------------------------ +perm-eyebrow = Oprávnění +perm-tabs-aria = sekce oprávnění +perm-tab-roles = Role +perm-tab-roles-sub = Vytváření rolí a jejich dědičnosti +perm-tab-people = Lidé +perm-tab-people-sub = Přiřadit uživateli roli +perm-tab-access = Přístup +perm-tab-access-sub = Co role může dělat +perm-unavailable-title = Oprávnění nejsou dostupná +perm-update-error = Oprávnění se nepodařilo aktualizovat +perm-forbidden = Tento účet nespravuje ani role, ani uživatele. +perm-err-missing-session = Chybějící relace +perm-err-nothing-to-change = Není co měnit: formulář neobsahoval žádná oprávnění. +perm-err-bad-pair = '{ $value }' není dvojice objekt|akce. +perm-err-not-editable-role = '{ $role }' není role, kterou můžete upravovat. +perm-err-neither-grant-nor-revoke = '{ $value }' není ani grant, ani zrušení. +perm-err-grant-failed = Udelení { $action } na { $object } se nezdařilo: { $error } +perm-err-revoke-failed = Zrušení { $action } na { $object } se nezdařilo: { $error } +perm-err-bad-starter-access = '{ $value }' není úroveň startovacího přístupu. +perm-role-created-grant-failed = Role '{ $role }' byla vytvořena, ale udelení { $action } na { $object } se nezdařilo: { $error } +perm-role-reason-outranks-you = má vyšší úroveň než vy +perm-role-reason-built-in = vestavěná +perm-role-reason-still-assigned = stále přiřazená + +roles-description = Role je pojmenovaná množina oprávnění. Lidem se přiřazují role; rolím se přiřazuje přístup. +roles-updated-notice = Role aktualizovány. Přihlášené relace zůstávají platné a změnu zachytí při nejbližším požadavku. +roles-new-heading = Nová role +roles-new-hint = Dědičnost z jiné role dá nové roli vše, co má tato role, a udržuje ji v souladu, když se tato role změní. Startovací přístup je zkratka pro dva běžné případy; cokoli užšího je pár kliků na kartě Přístup. +roles-name = Název +roles-inherits-from = Dědí z +roles-inherits-none = Nic — začíná prázdná +roles-starter-access = Startovací přístup +roles-access-none = Žádný — udělte ho na kartě Přístup +roles-access-read = Číst vše +roles-access-full = Číst a zapisovat vše +roles-create-button = Vytvořit roli +roles-existing = Existující role +roles-th-role = Role +roles-th-kind = Druh +roles-th-inherits = Dědí z +roles-th-people = Lidé +roles-th-access = Přístup +roles-th-remove = Odebrat +roles-built-in = vestavěná +roles-edit-access = Upravit přístup + +users-description = Přiřaďte uživateli roli. Co role může dělat, se určí jednou, na kartě Přístup. +users-updated-notice = Aktualizováno. Nejbližší požadavek dané osoby použije novou roli; nemusí se znovu přihlašovat. +users-heading = Uživatelé +users-outrank-hint = Můžete měnit jen někoho, jehož role je nižší než vaše — zde { $editable } z { $total }. +users-th-user = Uživatel +users-th-email = E-mail +users-th-role = Role +users-th-change = Změnit roli +users-th-reset = Obnovit heslo +users-assign = Přiřadit +users-confirm = Potvrdit +users-reset = Obnovit + +grants-description = Co role může dělat s daty. Každý, kdo roli drží, dostane přesně toto. +grants-updated-notice = Přístup aktualizován. Platí od nejbližšího požadavku každého držitele role. +grants-no-role = Není co upravovat +grants-no-role-hint = Není zde žádná role s nižší úrovní než vaše. Vytvořte jednu nejprve. +grants-role-heading = Role +grants-editing = Upravujete +grants-inherits-prefix = → dědí +grants-inherits-parent = { $role } dědí vše, co má { $parent }. Děděný přístup se zobrazuje níže, ale mění se na nadřazené roli. +grants-inherits-none = { $role } nedědí nic, takže vše, co vidíte níže, je vše, co má. +grants-structural-hint = Tato role navrhuje systém a nikdy nemůže zapisovat řádková data, takže je otevřený jen sloupec pro čtení. +grants-shortcuts = Zkratky +grants-shortcuts-hint = Udělují zástupné objekty, takže nadále pokrývají profily a tabulky vytvořené později. +grants-full-access-everything = Plný přístup ke všemu +grants-remove-all-access = Odebrat veškerý přístup +grants-everything-everywhere = Vše, všude +grants-profile-prefix = Profil: +grants-read-only = Pouze pro čtení +grants-full-access = Plný přístup +grants-clear = Vyčistit +grants-th-object = Objekt +grants-th-row = Řádek +grants-cell-unavailable = Pro tuto roli není dostupné +grants-cell-direct = Drží přímo — kliknutím zrušíte +grants-cell-inherited = zděděno: pokrývá to nadřazená role nebo širší grant +grants-cell-grant = Kliknutím udělíte +grants-all = Vše +grants-none = Nic +grants-read-everything-hint = Čtení na každém profilu, deníku a devizovém kurzu +grants-full-everything-hint = Každá akce, kterou role může držet, na každém profilu +grants-remove-all-hint = Odvolat každý grant, který tato role drží přímo +grants-read-only-hint = Čtení všeho zde +grants-full-access-hint = Každá akce, kterou role může držet, na všem zde +grants-clear-hint = Odvolat vše, co tato role drží přímo zde +grants-all-hint = Udělit každou akci dostupnou na tomto objektu +grants-none-hint = Odvolat přímé granty tohoto objektu +grants-everything-title = Vše +grants-label-all-tables = Všechny tabulky +grants-note-all-tables = Každá tabulka v každém profilu včetně těch, které přibudou později. +grants-label-all-journals = Všechny deníky +grants-note-all-journals = Účetní deník každého profilu. +grants-label-all-rates = Všechny devizové kurzy +grants-note-all-rates = Kurzy ECB importuje server, takže je lze pouze číst. +grants-note-profile-tables = Každá tabulka v tomto profilu včetně těch, které přibudou později. +grants-label-journal = Deník +grants-note-journal = Účetní deník tohoto profilu. +grants-label-rates = Devizové kurzy +grants-note-rates = Importuje server, takže jediným grantem je čtení. +grants-note-table = Tato tabulka a celá její šablonová rodina. + +# --- Analytika ----------------------------------------------------------------- +analytics-title = Analytika +analytics-eyebrow = Reportování +analytics-description = Dotazujte se na read-only analytické API a výsledek zobrazte v grafu. +analytics-available-data = Dostupná data +analytics-load-hint = Načtěte profil, abyste viděli aliasy tabulek a sloupců, které analytický SQL akceptuje. +analytics-profile = Profil +analytics-loading-profiles = Načítají se profily… +analytics-load-schema = Načíst schéma +analytics-no-schema = Není načteno žádné schéma. +analytics-chart = Graf +analytics-chart-bar = Sloupcový +analytics-chart-line = Čárový +analytics-chart-pie = Koláčový +analytics-chart-scatter = Bodový +analytics-chart-table = Tabulka +analytics-max-rows = Maximální počet řádků +analytics-sql = SQL +analytics-sql-placeholder = Načtěte schéma a poté vyberte startovací dotaz nebo napište SELECT dotaz +analytics-run-query = Spustit dotaz +analytics-running = Spouští se… +analytics-no-tables = Tento profil nemá analytické tabulky. +analytics-tables-available = { $count -> + [one] k dispozici { $count } tabulka + [few] k dispozici { $count } tabulky + *[other] k dispozici { $count } tabulek +} +analytics-use-starter = Použít startovací dotaz +analytics-links = Odkazy +analytics-required = povinné +analytics-llm-context = Kontext schématu pro LLM +analytics-llm-hint = Zkopírujte celý tento text a zahrňte ho, když požádáte LLM o napsání dotazu. +analytics-copy-context = Kopírovat kontext +analytics-copied = Zkopírováno +analytics-load-error = Profily se nepodařilo načíst: { $message } +analytics-no-profiles = Nejsou k dispozici žádné profily +analytics-choose-profile = Vyberte profil… +analytics-sign-in = Přihlaste se, abyste viděli profily, které můžete číst. +analytics-err-profile-required = Zadejte název profilu pro načtení jeho schématu +analytics-err-input-required = Vyžaduje se název profilu a SQL +analytics-err-chart-columns = Grafy vyžadují alespoň dva výsledné sloupce +analytics-err-chart-type = Neznámý typ grafu +analytics-not-signed-in = Nejste přihlášen pro { $action }. +analytics-action-loading-schema = načítání schématu +analytics-action-running-query = spouštění dotazu +analytics-result-meta = { $rows -> + [one] { $rows } řádek za { $elapsed } ms + [few] { $rows } řádky za { $elapsed } ms + *[other] { $rows } řádků za { $elapsed } ms +} +analytics-result-meta-truncated = { $rows -> + [one] { $rows } řádek za { $elapsed } ms (zkráceno) + [few] { $rows } řádky za { $elapsed } ms (zkráceno) + *[other] { $rows } řádků za { $elapsed } ms (zkráceno) +} + +# --- Import / export ------------------------------------------------------------- +transfer-eyebrow = Přenos dat +import-title = Import CSV +import-lead = Vyberte soubor v prohlížeči nebo vložte CSV. Řádky se ověřují proti živým strukturám tabulek před hromadným vkládáním přes gRPC. +import-profile = Profil +import-target-tables = Cílové tabulky +import-csv-file = Soubor CSV +import-csv-data = Data CSV +import-error-title = CSV se nepodařilo importovat +import-import-rows = Importovat řádky +import-success-title = Import dokončen +import-success-message = Vloženo { $inserted -> + [one] { $inserted } záznam + [few] { $inserted } záznamy + *[other] { $inserted } záznamů +} z { $source_rows -> + [one] { $source_rows } řádku CSV + [few] { $source_rows } řádků CSV + *[other] { $source_rows } řádků CSV +} ve { $table_count -> + [one] { $table_count } tabulce + [few] { $table_count } tabulkách + *[other] { $table_count } tabulkách +}. +import-err-select-profile = Vyberte profil. +import-err-tables-required = Zadejte alespoň jednu cílovou tabulku. +import-err-csv-required = Vyberte soubor CSV nebo vložte data CSV. +import-err-unknown-profile = Neznámý profil. +import-err-tables-not-in-profile = Jedna nebo více cílových tabulek nepatří do profilu. +import-err-missing-structure = Backend vynechal požadovanou strukturu tabulky. +import-err-no-importable-columns = CSV nemá importovatelné sloupce pro tabulku '{ $table }'. +import-err-column-not-importable = Sloupec '{ $column }' není importovatelný pro tabulku '{ $table }'. +import-err-csv-row = Řádek CSV { $row }: { $error } +import-err-multi-headers = Více-tabulkové CSV potřebuje řádek s hlavičkou tabulek a řádek s hlavičkou sloupců. +import-err-first-row = První řádek CSV musí obsahovat jen vybrané názvy tabulek. +import-err-header-lengths = Řádek hlavičky tabulek a sloupců mají různou délku. +import-err-row-width = Řádek dat CSV má jiný počet polí než hlavička. +import-err-no-data-rows = CSV obsahuje hlavičky, ale žádné řádky dat. +import-err-missing-type = Chybí typ sloupce '{ $column }' +import-err-permission = Vyžaduje se oprávnění k vkládání pro alespoň jednu čitelnou tabulku. +import-err-unterminated-quote = CSV obsahuje neuzavřenou uvozovkovou hodnotu. +import-err-empty = CSV je prázdné. +import-err-bad-boolean = Neplatná booleovská hodnota '{ $value }' +import-err-bad-integer = Neplatná celočíselná hodnota '{ $value }' + +export-title = Export CSV +export-lead = Soubor se generuje z živých dat tabulek přes gRPC. +export-profile = Profil +export-choose-profile = Vyberte profil +export-tables = Tabulky +export-comma-hint = Více tabulek oddělte čárkami. +export-error-title = CSV se nepodařilo exportovat +export-download = Stáhnout CSV +export-err-select-profile = Vyberte profil. +export-err-tables-required = Zadejte alespoň jednu tabulku. +export-err-unknown-profile = Neznámý profil. +export-err-tables-not-in-profile = Jedna nebo více tabulek nepatří do vybraného profilu. +export-err-missing-structure = Backend vynechal požadovanou strukturu tabulky. +export-err-negative-count = Backend vrátil záporný počet řádků. +export-err-range = Export přesahuje podporovaný rozsah pozic řádků. +export-err-permission = Vyžaduje se oprávnění ke čtení pro alespoň jednu tabulku. + +# --- Přidat logiku ---------------------------------------------------------------- +add-logic-title = Přidat logiku +add-logic-eyebrow = Vypočítaný sloupec +add-logic-lead = Vytvořte nebo aktualizujte Steel skript přes existující službu skriptů tabulek. +add-logic-table = Tabulka +add-logic-choose-table = Vyberte tabulku +add-logic-name = Název logiky +add-logic-target-column = Cílový sloupec +add-logic-script = Steel výraz +add-logic-description = Popis +add-logic-error-title = Logiku se nepodařilo uložit +add-logic-save = Uložit logiku +add-logic-success-title = Logika uložena +add-logic-saved = Skript #{ $id } byl uložen. +add-logic-saved-warnings = Skript #{ $id } byl uložen. Upozornění: { $warnings } +add-logic-err-select-table = Vyberte cílovou tabulku. +add-logic-err-target-column = Zadejte cílový sloupec. +add-logic-err-script = Zadejte Steel výraz. +add-logic-err-script-paren = Steel výraz musí začínat `(`. +add-logic-err-permission = Vyžaduje se oprávnění ke správě skriptů. + +# --- Nástroj pro přidání tabulky --------------------------------------------------- +add-table-title = Přidat tabulku +add-table-eyebrow = Definice tabulky +add-table-lead = Vyberte, zda tabulka patří jednomu profilu, nebo je dostupná všem profilům, a poté přidejte její sloupce. Po vytvoření můžete přiřadit počáteční oprávnění rolím. +builder-err-title = Tabulku se nepodařilo vytvořit +builder-err-permission = Vyžaduje se oprávnění ke správě tabulek. +builder-err-backend-no-create = Backend nevytvořil tabulku. +builder-could-not-continue = Nelze pokračovat +builder-draft-updated = Návrh aktualizován +builder-table-details = Podrobnosti tabulky +builder-scope-question = Kde má být tato tabulka dostupná? +builder-scope-profile = Jeden profil +builder-scope-profile-hint = Tuto tabulku může používat jen vybraný profil. +builder-scope-global = Všechny profily +builder-scope-global-hint = Tuto sdílenou tabulku může používat každý profil. +builder-which-profile = Který profil? +builder-choose-profile = Vyberte profil +builder-new-profile = Nový profil… +builder-new-profile-name = Název nového profilu +builder-accounting-currency = Účetní měna +builder-table-name = Název tabulky +builder-add-column-heading = Přidat sloupec +builder-add-column-button = Přidat sloupec +builder-shared-table-hint = Sdílená tabulka nevede vlastní účetnictví, takže sloupce ACCOUNTING, ACCOUNTING_TRANSFER a kvantitativní evidence se zde nenabízejí — každý z nich se účtuje do deníku jednoho profilu. +builder-compound-intro = je definiční řádek, ne vlastní sloupec. Jeho přidáním tabulka získá: +builder-inherits-currency-hint = drženo v měně a zaokrouhlení zvolených výše +builder-account-system-hint = systémový sloupec — účet, na který se účtuje každý řádek, zapisovaný jako account +builder-rename-generated-hint = Každému z nich můžete dát vlastní název v části Přejmenovat generované sloupce hned po přidání řádku; prázdné pole ponechá název zobrazený zde. +builder-reserved-hint = Tyto názvy jsou vyhrazené: žádný vlastní sloupec je nesmí použít a tabulka může obsahovat jen jeden { $type }. Umístí se na místo tohoto řádku v seznamu sloupců a lze je s ním přesouvat. +builder-columns-heading = Sloupce +builder-no-columns = Zatím žádné sloupce. Popište jeden výše a stiskněte +builder-columns-order-hint = Sloupce se vytvářejí v tomto pořadí. Šipkami můžete jeden přesunout. +builder-th-order = Pořadí +builder-th-name = Název +builder-th-type = Typ +builder-th-indexed = Indexovaný +builder-th-options = Možnosti +builder-move-up = Posunout { $name } nahoru +builder-move-down = Posunout { $name } dolů +builder-renamed-to = přejmenován na { $alias } +builder-rename-generated = Přejmenovat generované sloupce +builder-rename-generated-hint2 = Tyto sloupce pocházejí z definičních řádků výše. Dejte některému vlastní název, nebo ho nechte prázdný, aby zůstal zobrazený název. +builder-rename-aria = Přejmenovat { $name } +builder-apply-names = Použít názvy +builder-row-display = Sloupce identifikující řádek +builder-row-display-hint = Co uživatelům identifikuje řádek, ve zvoleném pořadí. Výběrem id se seznam vyprázdní. +builder-preview = Náhled definice tabulky +builder-preview-th-column = Sloupec +builder-preview-th-type = Typ +builder-preview-th-options = Možnosti +builder-preview-th-source = Zdroj +builder-preview-primary-key = primární klíč +builder-preview-default-false = výchozí false +builder-preview-not-null-1 = not null, výchozí 1 +builder-preview-current-time = aktuální čas +builder-preview-account-option = not null, → účty, zapisováno jako { $field } +builder-option-indexed = indexovaný +builder-create-table = Vytvořit tabulku +builder-status-generated-keep = Generované sloupce si ponechávají své názvy. +builder-status-generated-one = Přejmenován 1 generovaný sloupec. +builder-status-generated-many = { $count -> + [one] Přejmenován { $count } generovaný sloupec + [few] Přejmenovány { $count } generované sloupce + *[other] Přejmenováno { $count } generovaných sloupců +}. + +draft-err-profile-name = Zadejte název nového profilu. +draft-err-table-conflict-global = Tabulka s názvem `{ $name }` už existuje. Název sdílené tabulky musí být volný v každém profilu. +draft-err-table-conflict = Tabulka s názvem `{ $name }` už existuje v profilu `{ $profile }`, nebo je sdílená všemi profily. +draft-err-no-columns = Před uložením přidejte alespoň jeden sloupec. +draft-err-alias-taken = Alias `{ $alias }` pro generovaný sloupec `{ $source }` už používá jiný sloupec. +draft-err-accounting-currency = Účetní měna musí být třímístný kód ISO-4217 + +schema-status-column-added = Sloupec `{ $name }` přidán. +schema-status-column-removed = Sloupec `{ $name }` odebrán. +schema-status-column-moved-up = Sloupec `{ $name }` posunut nahoru. +schema-status-column-moved-down = Sloupec `{ $name }` posunut dolů. + +schema-err-both-name-type = Vyžaduje se název sloupce i typ sloupce. +schema-err-creation-only = Sloupec typu { $type } lze vybrat jen při vytváření tabulky. +schema-err-column-exists = Sloupec s názvem `{ $name }` už existuje. +schema-err-generated-exists = `{ $type }` generuje sloupec s názvem `{ $generated }` a tabulka ho už má. +schema-err-ql-types = Sloupce kvantitativní evidence musí používat { $types } +schema-err-column-ql-types = Sloupec `{ $name }`: sloupce kvantitativní evidence musí používat { $types } +schema-err-column-prefix = Sloupec `{ $name }`: { $error } +schema-err-decimal-not-valid = `decimal` není platný typ sloupce. +schema-err-link-not-valid = `link` není platný typ sloupce. +schema-err-decimal-args-needed = `{ $type }` vyžaduje přesnost i desetinná místa. +schema-err-link-target-needed = `link` vyžaduje odkazovanou tabulku. +schema-err-generated-type = `{ $type }` je typ sloupce, který generuje backend. +schema-err-invalid-type = `{ $type }` není platný typ sloupce. +schema-err-account-link = Vztah účtu je zabudovaný do ACCOUNTING a nelze ho deklarovat jako odkaz. +schema-err-link-self = Odkaz `{ $column }` nemůže ukazovat na vytvářenou tabulku. +schema-err-column-gone = Tento sloupec už neexistuje. +schema-err-link-indexed = Odkaz `{ $name }` je indexován automaticky a nelze ho indexovat znovu. +schema-err-definition-indexed = `{ $name }` je definiční řádek, ne sloupec, takže ho nelze indexovat. +schema-err-currency-only = Sloupec `{ $name }`: měna patří jen typu sloupce, který ji vyžaduje. +schema-err-name-twice = `{ $name }` pojmenovává více než jeden sloupec této tabulky. +schema-err-currency-code = Měna musí být třímístný kód ISO-4217 +schema-err-profile-only-type = Sdílená tabulka nemůže používat { $type }: účtuje do deníku jednoho profilu a sdílená tabulka patří každému profilu. +schema-err-shared-ql = Sloupec `{ $name }`: sdílená tabulka nemůže vést kvantitativní evidenci, která patří jednomu profilu. +schema-err-profile-reserved = Tento název profilu je vyhrazen PostgreSQL. +schema-err-table-name-too-long = Název tabulky nemůže být delší než 63 znaků, protože indexy jejích sloupců jsou pojmenované po ní. +schema-err-table-name-reserved = `{ $name }` je název tabulky, kterou dostane každý profil, takže ho nelze použít. + +error-identifier-empty = { $label } nemůže být prázdné. +error-identifier-whitespace = { $label } nesmí začínat ani končit mezerou. +error-identifier-underscore = { $label } nesmí začínat podtržítkem. +error-identifier-number = { $label } nesmí začínat číslicí. +error-identifier-too-long = { $label } nesmí být delší než 63 znaků. +error-identifier-charset = { $label } může obsahovat jen malá písmena, číslice a podtržítko. +error-identifier-reserved = { $label } používá vyhrazený název. +error-decimal-required = { $label } je povinné pro desetinný sloupec. +error-decimal-sign = { $label } nesmí obsahovat znaménko. +error-decimal-whole = { $label } musí být celé číslo. +error-decimal-leading-zeros = { $label } nesmí mít úvodní nuly. +error-precision-min = Přesnost musí být alespoň 1. +error-scale-gt-precision = Desetinná místa nemohou být více než přesnost. + +# --- Přidat validaci ---------------------------------------------------------------- +validation-eyebrow = Validace +validation-library-eyebrow = Globální knihovna validací +validation-field-title = Přidat validaci pole +validation-field-lead = Připojte validaci k jednomu sloupci jedné tabulky. +validation-rule-title = Přidat opakovaně použitelné validační pravidlo +validation-rule-lead = Pojmenované pravidlo, ze kterého mohou validační sady skládat, nezávislé na jakékoli tabulce. +validation-set-title = Přidat validační sadu +validation-set-lead = Sestavte uspořádanou sadu z existujících názvů opakovaně použitelných pravidel. +validation-profile = Profil +validation-table = Tabulka +validation-column = Sloupec +validation-apply-set = Místo toho použít pojmenovanou validační sadu +validation-apply-set-placeholder = Nechte prázdné, aby se uložila pravidla níže +validation-error-title = Validaci se nepodařilo uložit +validation-success-title = Validace uložena +validation-save = Uložit validaci +validation-rule-name = Název pravidla +validation-description = Popis +validation-save-rule = Uložit pravidlo +validation-set-name = Název sady +validation-reusable-rules = Opakovaně použitelná pravidla +validation-reusable-rules-hint = Oddělte čárkami a aplikují se v tomto pořadí. +validation-save-set = Uložit sadu +validation-position-rules = Pravidla pozic +validation-position-rules-hint = Jeden na řádek: pozice | omezení. Pozice: 0, 0-3, 4+ nebo 1,3,5. Omezení: alphabetic, numeric, alphanumeric, exact=x, one-of=a,b, regex=… +validation-pattern-description = Popis vzoru +validation-min-length = Minimální délka +validation-max-length = Maximální délka +validation-warn-at = Hranice upozornění +validation-count-mode = Režim počítání +validation-count-chars = Znaky +validation-count-bytes = Bajty +validation-count-display-width = Zobrazovaná šířka +validation-allowed-values = Povolené hodnoty +validation-display-mask = Zobrazovací maska +validation-mask-input-char = Vstupní znak masky +validation-mask-template-char = Šablonový znak masky +validation-required = Povinné +validation-allow-empty = Povolit prázdné +validation-case-insensitive = Nerozlišovat velikost písmen +validation-external = Externí validace +validation-lock = Zamknout konfiguraci +validation-err-at-least-one-rule = Zadejte alespoň jeden název opakovaně použitelného pravidla. +validation-err-enter = Zadejte { $label }. +validation-err-non-negative = { $label } musí být nezáporné celé číslo. +validation-err-min-max = Minimum nemůže být větší než maximum. +validation-err-pattern-line = Řádek vzoru { $line } musí používat `pozice | omezení`. +validation-err-range-start = Začátek rozsahu vzoru nemůže přesáhnout jeho konec. +validation-err-bad-position = Neplatná pozice vzoru '{ $value }'. +validation-err-bad-constraint = Neznámé omezení znaků '{ $value }'. +validation-err-permission = Vyžaduje se oprávnění ke správě validací. diff --git a/web/locales/en/main.ftl b/web/locales/en/main.ftl index 175cb538..73eef2ae 100644 --- a/web/locales/en/main.ftl +++ b/web/locales/en/main.ftl @@ -1 +1,786 @@ -# English is the source locale. Add every new key here first. +# ============================================================================ +# English (en) — SOURCE locale. +# +# Every key that exists here MUST also exist in locales/sk/main.ftl and +# locales/cs/main.ftl. Do NOT translate this file. +# +# Key naming: - (kebab-case, grouped by comment +# blocks). Templates call `nav.tr("key")`; Rust calls `tr!(locale, "key")`. +# ============================================================================ + +# --- Common / shared words ------------------------------------------------ +common-cancel = Cancel +common-no = No +common-remove = Remove +common-yes = Yes + +# --- Page chrome (ui/, navbar) -------------------------------------------- +ui-back-to-admin = Back to the admin panel +ui-back-to-admin-panel = ← Admin panel +ui-back-to-form = Back to the form +ui-dismiss-alert = dismiss alert +notice-log-in-first = Please log in first. + +nav-admin = Admin +nav-permissions = Permissions +nav-analytics = Analytics +nav-rates = Rates +nav-import = Import +nav-export = Export +nav-password = Password +nav-log-out = Log out +nav-login = Login +nav-register = Register +nav-main-menu = main menu +nav-mobile-menu = mobile menu + +# --- Field labels used inside composed messages ---------------------------- +label-profile = profile +label-table = table +label-column = column +label-set-name = set name +label-rule-name = rule name +label-validation-set-name = validation set name +label-minimum = Minimum +label-maximum = Maximum +label-warning-threshold = Warning threshold +label-precision = Precision +label-scale = Scale +label-column-name = Column name +label-table-name = Table name +label-linked-table = Linked table +label-column-alias = Column alias +label-profile-name = Profile name + +# --- Column badges --------------------------------------------------------- +column-flag-system = system +column-flag-primary-key = primary key +column-flag-nullable = nullable +column-flag-required = required +column-flag-quantity-ledger = quantity ledger +column-flag-half-up = half-up +column-flag-read-only = read only +column-flag-generated = generated +column-flag-generated-from = generated from { $source } + +# --- Login / register / change password ------------------------------------ +login-title = Sign in +login-username-or-email = Username or email +login-password = Password +login-optional = (optional) +login-button-login = Login +login-no-account = No account yet? +login-error-title = Could not sign in +login-error-identifier-required = Username or email is required +login-error-invalid-token = The server returned an invalid access token + +register-title = Register +register-button-register = Register +register-confirm-password = Confirm password +register-have-account = Already have an account? +register-error-title = Could not register +register-success-title = Registration successful. You can sign in now. +register-success-message = + User ID: { $id } + Username: { $username } + Email: { $email } + Role: { $role } + Timezone: { $timezone } + Phone country: { $phone_country } + +field-username = Username +field-email = Email +field-timezone = Timezone +field-phone-country = Phone country +field-current-password = Current password +field-new-password = New password +field-confirm-new-password = Confirm new password + +password-title = Change password +password-button-change = Change password +password-empty-current-hint = For a freshly seeded admin or superadmin account, leave the current password empty. +password-error-title = Could not change password +password-success-title = Password changed +password-changed-message = Your new password is active. + +# --- Admin panel (workspace browser) --------------------------------------- +admin-title = Admin panel +admin-eyebrow-workspace = Workspace +admin-description = Browse profiles, tables, and their physical columns. Pick a table to reach what can be done to it. +admin-add-table = Add table +admin-add-logic = Add logic +admin-add-validation = Add validation +admin-add-rule = Add rule +admin-export = Export +admin-exchange-rates = Exchange rates +admin-unavailable-title = Admin panel unavailable +admin-forbidden = This account is not allowed to open the admin panel. + +admin-profiles = Profiles +admin-no-profiles-available = No profiles available. +admin-table-count = { $count -> + [one] { $count } table + *[other] { $count } tables +} +admin-shared-by-every-profile = shared by every profile +admin-tables-heading = Tables +admin-select-profile-first = Select a profile to see its tables. +admin-no-global-tables = There are no global tables. +admin-no-profile-tables = This profile has no tables of its own. +admin-no-dependencies = No dependencies +admin-depends-on-prefix = Depends on +admin-row-display-prefix = display: +admin-add-rename-columns = Add / rename columns +admin-delete-table = Delete table +admin-who-may-use-it = Who may use it +admin-pick-table-hint = Pick a table to add columns to it, delete it, or say who may use it. +admin-new-global-table = New global table +admin-new-table = New table +admin-from-template = From template +admin-copy-profile = Copy profile +admin-rename-history = Rename history +admin-columns = Columns +admin-column-counts = { $user -> + [one] { $user } user + *[other] { $user } users +} · { $system -> + [one] { $system } system + *[other] { $system } system +} +admin-select-table-inspect = Select a table to inspect its columns. +admin-no-visible-columns = This table has no visible columns. +admin-system-columns-label = System columns · hidden from users + +# --- Table definition pages (columns / delete / copy / template / history) - +td-columns-title = Columns +td-add-columns-to = Add columns to +td-append-hint = Columns are appended. Nothing that already exists is changed, and the new columns can be indexed as they are added. +td-rename-column = Rename a column +td-rename-hint = Renames what the column is called, not the physical column underneath, so stored data and scripts are untouched. Only possible while the table has no rows. +td-column-label = Column +td-choose-column = Choose a column +td-new-name = New name +td-rename-button = Rename column +td-columns-now = Columns it has now +td-col-column = Column +td-col-type = Type +td-col-notes = Notes +td-scripts = Scripts +td-col-target-column = Target column +td-col-description = Description +td-source = source + +td-could-not-add-column = Could not add the column +td-staged = Staged +td-link-alias = Link alias +td-column-name = Column name +td-link-alias-hint = The alias is the column name used to distinguish this relationship. +td-column-type = Column type +td-choose-type = Choose a type +td-fk-link = FKlink +td-temporal-type = Temporal type +td-choose-temporal-type = Choose a temporal type +td-gtin-type = GTIN type +td-choose-gtin-length = Choose a GTIN length +td-precision = Precision +td-precision-hint = Total digits stored, at least 1. +td-scale = Scale +td-scale-hint = Digits after the point, no more than the precision. +td-referenced-table = Referenced table +td-choose-table = Choose a table +td-group-global = Global +td-group-user = User-created +td-group-system = System-created +td-no-eligible-tables = No eligible tables exist in this scope. +td-currency = Currency +td-rounding = Rounding +td-rounding-none = none +td-rounding-half-up = half-up +td-indexing = Indexing +td-indexed-automatically = Indexed automatically — a link is a foreign key, and the server indexes every one of them. +td-required = Required +td-required-hint = A row must carry a value for it. +td-quantity-ledger = Quantity ledger +td-ql-only = { $types } only. +td-shared-no-quantity-ledger = A shared table keeps no books of its own, so it cannot hold a quantity-ledger column. +td-stage-column = Stage column +td-no-staged-columns = No columns staged yet. Describe one above and press +td-col-name = Name +td-col-indexed = Indexed +td-col-options = Options +td-indexed-automatically-tag = indexed automatically +td-money-exact = exact +td-money-half-up = half-up +td-add-columns-button = { $count -> + [one] Add { $count } column to the table + *[other] Add { $count } columns to the table +} + +td-eyebrow-table-definition = Table definition +td-columns-count = { $count -> + [one] { $count } column + *[other] { $count } columns +} +td-identified-by = identified by +td-its-id = its id +td-depends-on-prefix = depends on +td-acts-on-profile = Everything on this page acts on this profile. +td-global-label = Global — all profiles +td-tab-aria = table actions +td-tab-columns = Columns +td-tab-columns-sub = Append columns and rename them +td-tab-delete = Delete +td-tab-delete-sub = Drop this table +td-tab-access = Access +td-tab-access-sub = Which roles may read and write it +td-tab-copy = Copy profile +td-tab-copy-sub = Duplicate its structure +td-tab-template = From template +td-tab-template-sub = Create tables from a Typst invoice +td-tab-history = Rename history +td-tab-history-sub = How columns got their names +td-backend-managed-title = Backend-managed +td-backend-managed-hint = This table is the backend's own. Its definition is visible in the admin panel, but it can only be changed through the backend's own APIs, so none of the write actions apply to it. + +td-could-not-continue = Could not continue +td-done = Done +td-sql-ran = SQL the backend ran +td-delete-title = Delete table +td-delete-table = Delete table +td-delete-hint = Drops the table and its definition, and the profile too when this was its last table. The backend refuses to delete a table that still has rows. +td-links-to = It links to { $tables }. +td-type-to-confirm = Type +td-no-table-chosen = No table chosen +td-pick-table-delete = Pick the table to delete in the admin panel first. +td-copy-title = Copy profile +td-copy-into = Copy +td-copy-hint = Copies structure — tables, links and scripts — and no rows. Leave every table unticked to copy the whole profile. +td-new-profile-name = New profile name +td-copy-button = Copy profile +td-template-title = Create from template +td-template-created-tables = Tables created from the template +td-col-table = Table +td-col-collection = Collection +td-col-parent = Parent +td-root = root +td-template-create-heading = Create tables from an invoice template +td-template-hint = Reads the #let komp_ac_fields = (…) contract out of a Typst template. A path that matches an existing table and column becomes a reference to it; anything else becomes a TEXT column to refine later, and every [] becomes a child table. +td-table-name = Table name +td-row-display-columns = Row display columns +td-row-display-hint = Comma-separated, and only for the root table. +td-template-source = Template source +td-template-create-button = Create from template +td-history-title = Rename history +td-history-heading = Column rename history +td-history-empty = No column in { $scope } has been renamed. +td-history-empty-this-profile = No column in this profile has been renamed. +td-col-was = Was +td-col-is = Is +td-col-when = When + +td-columns-added = { $count -> + [one] { $count } column added to `{ $table }`. + *[other] { $count } columns added to `{ $table }`. +} +td-profile-copied = { $message } — { $tables -> + [one] { $tables } table + *[other] { $tables } tables +} and { $scripts -> + [one] { $scripts } script + *[other] { $scripts } scripts +} copied. +td-template-tables-created = { $count -> + [one] { $count } table created from the template. + *[other] { $count } tables created from the template. +} +td-err-select-table-first = Select a table first. +td-err-profile-gone = Profile `{ $missing }` no longer exists. +td-err-not-a-table = `{ $missing }` is not a table of { $scope }. +td-err-describe-column = Describe at least one column before adding. +td-err-backend-no-columns = The backend did not add the columns. +td-err-choose-rename = Choose a column and type its new name. +td-err-backend-no-rename = The backend did not rename the column. +td-err-type-name = Type the table's name exactly to confirm the deletion. +td-err-backend-no-delete = The backend did not delete the table. +td-err-name-profile = Name the profile to copy into. +td-err-backend-no-copy = The backend did not copy the profile. +td-err-template-required = A table name and the template's source are both required. +td-err-backend-no-template = The backend did not create the template's tables. +td-err-permission = Table-management permission is required. +td-unavailable-title = Table definition unavailable + +# --- ECB reference rates --------------------------------------------------- +ecb-title = Exchange rates +ecb-heading = ECB reference rates +ecb-description = The importer fetches the ECB's published rates once a day and records every attempt. Conversions read only what it has verified, so coverage is what decides whether a foreign-currency amount can be posted at all. +ecb-unavailable-title = Exchange rates unavailable +ecb-forbidden = Reading exchange-rate data is required to open this page. +ecb-import-attempts = Import attempts +ecb-failed-count = { $count -> + [one] { $count } failed + *[other] { $count } failed +} +ecb-no-attempts-hint = The importer has not recorded an attempt. It runs on startup and then daily, so an empty log means the server has not completed a run since this table was created. +ecb-th-result = Result +ecb-th-started = Started +ecb-th-finished = Finished +ecb-th-took = Took +ecb-th-window = Window +ecb-th-observations = Observations +ecb-th-verified-through = Verified through +ecb-batch-succeeded = succeeded +ecb-batch-running = running +ecb-batch-failed = failed +ecb-still-running = still running +ecb-audit-hint = Attempts are an audit record: they cannot be edited or deleted, and a batch that re-fetched a day an earlier one already owned inserts nothing, which is why the new count can be lower than the total. +ecb-import-running = import running +ecb-postable-lead = What can be posted right now +ecb-ordinary-transactions = Ordinary transactions +ecb-statements-title = Statements & decisive dates +ecb-dated-up-to = dated up to and including +ecb-ordinary-rate-hint = Converted at the rate published the business day before the transaction. +ecb-statements-rate-hint = Converted at the rate published on that day, or the last business day before it. +ecb-postable-limit-hint = A foreign-currency amount dated after its limit is refused outright until the importer catches up — it is not converted at a stale rate. +ecb-nothing = Nothing. +ecb-no-publication-hint = No publication has ever been verified, so every foreign-currency amount is refused whatever its date. +ecb-verified-through = Verified through +ecb-never = never +ecb-up-to-date = up to date +ecb-behind-count = { $count -> + [one] { $count } publication day behind + *[other] { $count } publication days behind +} +ecb-no-coverage = no coverage +ecb-should-reach = Should reach +ecb-last-attempt = Last attempt +ecb-none-recorded = none recorded +ecb-last-success = Last success +ecb-none = none +ecb-next-import = Next import +ecb-currencies-on = Currencies on { $date } +ecb-the-covered-day = the covered day +ecb-most-recent-failure = Most recent failure — batch { $batch }, started { $started }: +ecb-no-reason-recorded = no reason was recorded. +ecb-headline-current = Rates are current +ecb-headline-never-ran = No rates have ever been imported +ecb-headline-behind = Rates are behind +ecb-explanation-current = Every publication day up to the latest one is verified, so conversions post normally. +ecb-explanation-never-ran = Until one succeeds, every conversion in a foreign currency is refused for want of coverage. +ecb-explanation-behind = { $count -> + [one] { $count } publication day is missing. + *[other] { $count } publication days are missing. +} A conversion dated inside the gap is refused until the importer catches up. +ecb-new-of = { $inserted } new of { $total } +ecb-in = in { $time } +ecb-ago = { $time } ago +ecb-less-than-1s = <1s + +# --- Permissions (roles / users / grants) ---------------------------------- +perm-eyebrow = Permissions +perm-tabs-aria = permission sections +perm-tab-roles = Roles +perm-tab-roles-sub = Create roles and their inheritance +perm-tab-people = People +perm-tab-people-sub = Give a user a role +perm-tab-access = Access +perm-tab-access-sub = What a role may do +perm-unavailable-title = Permissions unavailable +perm-update-error = Could not update permissions +perm-forbidden = This account manages neither roles nor users. +perm-err-missing-session = Missing session +perm-err-nothing-to-change = Nothing to change: the form carried no permissions. +perm-err-bad-pair = '{ $value }' is not an object|action pair. +perm-err-not-editable-role = '{ $role }' is not a role you may edit. +perm-err-neither-grant-nor-revoke = '{ $value }' is neither grant nor revoke. +perm-err-grant-failed = Granting { $action } on { $object } failed: { $error } +perm-err-revoke-failed = Revoking { $action } on { $object } failed: { $error } +perm-err-bad-starter-access = '{ $value }' is not a starter access level. +perm-role-created-grant-failed = Role '{ $role }' was created, but granting { $action } on { $object } failed: { $error } +perm-role-reason-outranks-you = outranks you +perm-role-reason-built-in = built in +perm-role-reason-still-assigned = still assigned + +roles-description = A role is a named set of permissions. People are given roles; roles are given access. +roles-updated-notice = Roles updated. Signed-in sessions stay valid and pick up the change on their next request. +roles-new-heading = New role +roles-new-hint = Inheriting from another role starts the new one with everything that role has, and keeps it in step as that role changes. Starter access is a shortcut for the two common cases; anything narrower is a few clicks on the Access tab. +roles-name = Name +roles-inherits-from = Inherits from +roles-inherits-none = Nothing — starts empty +roles-starter-access = Starter access +roles-access-none = None — grant it on the Access tab +roles-access-read = Read everything +roles-access-full = Read and write everything +roles-create-button = Create role +roles-existing = Existing roles +roles-th-role = Role +roles-th-kind = Kind +roles-th-inherits = Inherits from +roles-th-people = People +roles-th-access = Access +roles-th-remove = Remove +roles-built-in = built in +roles-edit-access = Edit access + +users-description = Give a user a role. What that role may do is decided once, on the Access tab. +users-updated-notice = Updated. The person's next request uses the new role; they do not have to sign in again. +users-heading = Users +users-outrank-hint = You can only change someone whose role you outrank — { $editable } of { $total } here. +users-th-user = User +users-th-email = Email +users-th-role = Role +users-th-change = Change role +users-th-reset = Reset password +users-assign = Assign +users-confirm = Confirm +users-reset = Reset + +grants-description = What a role may do with the data. Everyone holding the role gets exactly this. +grants-updated-notice = Access updated. It applies to the next request every holder of the role makes. +grants-no-role = No role to edit +grants-no-role-hint = There is no role here you outrank. Create one first. +grants-role-heading = Role +grants-editing = Editing +grants-inherits-prefix = → inherits +grants-inherits-parent = { $role } inherits everything { $parent } has. Inherited access shows below but is changed on the parent. +grants-inherits-none = { $role } inherits from nothing, so what you see below is all it has. +grants-structural-hint = This role designs the system and may never write row data, so only the read column is open. +grants-shortcuts = Shortcuts +grants-shortcuts-hint = These grant the wildcard objects, so they keep covering profiles and tables created later. +grants-full-access-everything = Full access to everything +grants-remove-all-access = Remove all access +grants-everything-everywhere = Everything, everywhere +grants-profile-prefix = Profile: +grants-read-only = Read only +grants-full-access = Full access +grants-clear = Clear +grants-th-object = Object +grants-th-row = Row +grants-cell-unavailable = Not available for this role +grants-cell-direct = Held directly — click to revoke +grants-cell-inherited = inherited: covered by the parent role or a wider grant +grants-cell-grant = Click to grant +grants-all = All +grants-none = None +grants-read-everything-hint = Read on every profile, journal and exchange rate +grants-full-everything-hint = Every action the role may hold, on every profile +grants-remove-all-hint = Revoke every grant this role holds directly +grants-read-only-hint = Read on everything in here +grants-full-access-hint = Every action the role may hold, on everything in here +grants-clear-hint = Revoke everything this role holds directly in here +grants-all-hint = Grant every action available on this object +grants-none-hint = Revoke this object's direct grants +grants-everything-title = Everything +grants-label-all-tables = All tables +grants-note-all-tables = Every table in every profile, including ones added later. +grants-label-all-journals = All journals +grants-note-all-journals = Every profile's accounting journal. +grants-label-all-rates = All exchange rates +grants-note-all-rates = ECB rates are imported by the server, so they can only be read. +grants-note-profile-tables = Every table in this profile, including ones added later. +grants-label-journal = Journal +grants-note-journal = This profile's accounting journal. +grants-label-rates = Exchange rates +grants-note-rates = Imported by the server, so read is the only grant. +grants-note-table = This table and its whole template family. + +# --- Analytics ------------------------------------------------------------- +analytics-title = Analytics +analytics-eyebrow = Reporting +analytics-description = Query the read-only analytics API and chart the result. +analytics-available-data = Available data +analytics-load-hint = Load a profile to see the table and column aliases accepted by analytics SQL. +analytics-profile = Profile +analytics-loading-profiles = Loading profiles… +analytics-load-schema = Load schema +analytics-no-schema = No schema loaded. +analytics-chart = Chart +analytics-chart-bar = Bar +analytics-chart-line = Line +analytics-chart-pie = Pie +analytics-chart-scatter = Scatter +analytics-chart-table = Table +analytics-max-rows = Max rows +analytics-sql = SQL +analytics-sql-placeholder = Load the schema, then choose a starter query or write a SELECT query +analytics-run-query = Run query +analytics-running = Running… +analytics-no-tables = This profile has no analytics tables. +analytics-tables-available = { $count -> + [one] { $count } table available + *[other] { $count } tables available +} +analytics-use-starter = Use starter query +analytics-links = Links +analytics-required = required +analytics-llm-context = LLM schema context +analytics-llm-hint = Copy this entire text and include it when asking an LLM to write a query. +analytics-copy-context = Copy context +analytics-copied = Copied +analytics-load-error = Could not load profiles: { $message } +analytics-no-profiles = No profiles available +analytics-choose-profile = Choose a profile… +analytics-sign-in = Sign in to list the profiles you may read. +analytics-err-profile-required = Enter a profile name to load its schema +analytics-err-input-required = Profile name and SQL are required +analytics-err-chart-columns = Charts require at least two result columns +analytics-err-chart-type = Unknown chart type +analytics-not-signed-in = You are not signed in for { $action }. +analytics-action-loading-schema = loading the schema +analytics-action-running-query = running a query +analytics-result-meta = { $rows -> + [one] { $rows } row + *[other] { $rows } rows +} in { $elapsed } ms +analytics-result-meta-truncated = { $rows -> + [one] { $rows } row + *[other] { $rows } rows +} in { $elapsed } ms (truncated) + +# --- Import / export ------------------------------------------------------- +transfer-eyebrow = Data transfer +import-title = Import CSV +import-lead = Choose a browser-local file or paste CSV. Rows are validated against live table structures before gRPC bulk insertion. +import-profile = Profile +import-target-tables = Target tables +import-csv-file = CSV file +import-csv-data = CSV data +import-error-title = Could not import CSV +import-import-rows = Import rows +import-success-title = Import complete +import-success-message = Inserted { $inserted -> + [one] { $inserted } record + *[other] { $inserted } records +} from { $source_rows -> + [one] { $source_rows } CSV row + *[other] { $source_rows } CSV rows +} across { $table_count -> + [one] { $table_count } table + *[other] { $table_count } tables +}. +import-err-select-profile = Select a profile. +import-err-tables-required = Enter at least one target table. +import-err-csv-required = Choose a CSV file or paste CSV data. +import-err-unknown-profile = Unknown profile. +import-err-tables-not-in-profile = One or more target tables do not belong to the profile. +import-err-missing-structure = The backend omitted a requested table structure. +import-err-no-importable-columns = CSV has no importable columns for table '{ $table }'. +import-err-column-not-importable = Column '{ $column }' is not importable for table '{ $table }'. +import-err-csv-row = CSV row { $row }: { $error } +import-err-multi-headers = Multi-table CSV needs a table-header row and a column-header row. +import-err-first-row = The first CSV row must contain only selected table names. +import-err-header-lengths = Table and column header rows have different lengths. +import-err-row-width = A CSV data row has a different number of fields than the header. +import-err-no-data-rows = CSV contains headers but no data rows. +import-err-missing-type = Missing type for column '{ $column }' +import-err-permission = Insert permission is required for at least one readable table. +import-err-unterminated-quote = CSV contains an unterminated quoted value. +import-err-empty = CSV is empty. +import-err-bad-boolean = Invalid boolean value '{ $value }' +import-err-bad-integer = Invalid integer value '{ $value }' + +export-title = Export CSV +export-lead = The download is generated from live table data through gRPC. +export-profile = Profile +export-choose-profile = Choose a profile +export-tables = Tables +export-comma-hint = Separate multiple tables with commas. +export-error-title = Could not export CSV +export-download = Download CSV +export-err-select-profile = Select a profile. +export-err-tables-required = Enter at least one table. +export-err-unknown-profile = Unknown profile. +export-err-tables-not-in-profile = One or more tables do not belong to the selected profile. +export-err-missing-structure = The backend omitted a requested table structure. +export-err-negative-count = The backend returned a negative row count. +export-err-range = The export exceeds the supported row-position range. +export-err-permission = Read permission is required for at least one table. + +# --- Add logic ------------------------------------------------------------- +add-logic-title = Add logic +add-logic-eyebrow = Computed column +add-logic-lead = Create or update a Steel script through the existing table-script service. +add-logic-table = Table +add-logic-choose-table = Choose a table +add-logic-name = Logic name +add-logic-target-column = Target column +add-logic-script = Steel expression +add-logic-description = Description +add-logic-error-title = Could not save the logic +add-logic-save = Save logic +add-logic-success-title = Logic saved +add-logic-saved = Script #{ $id } was saved. +add-logic-saved-warnings = Script #{ $id } was saved. Warnings: { $warnings } +add-logic-err-select-table = Select a target table. +add-logic-err-target-column = Enter the target column. +add-logic-err-script = Enter a Steel expression. +add-logic-err-script-paren = The Steel expression must start with `(`. +add-logic-err-permission = Script-management permission is required. + +# --- Add table builder ----------------------------------------------------- +add-table-title = Add table +add-table-eyebrow = Table definition +add-table-lead = Choose whether the table belongs to one profile or is available to all profiles, then add its columns. After creation, you can assign the initial role permissions. +builder-err-title = Could not create the table +builder-err-permission = Table-management permission is required. +builder-err-backend-no-create = The backend did not create the table. +builder-could-not-continue = Could not continue +builder-draft-updated = Draft updated +builder-table-details = Table details +builder-scope-question = Where should this table be available? +builder-scope-profile = One profile +builder-scope-profile-hint = Only the selected profile can use this table. +builder-scope-global = All profiles +builder-scope-global-hint = Every profile can use this shared table. +builder-which-profile = Which profile? +builder-choose-profile = Choose a profile +builder-new-profile = New profile… +builder-new-profile-name = New profile name +builder-accounting-currency = Accounting currency +builder-table-name = Table name +builder-add-column-heading = Add a column +builder-add-column-button = Add column +builder-shared-table-hint = A shared table keeps no books of its own, so ACCOUNTING, ACCOUNTING_TRANSFER and quantity-ledger columns are not offered here — each of them posts to one profile's ledger. +builder-compound-intro = is a definition row, not a column of its own. Adding it gives the table: +builder-inherits-currency-hint = kept in the currency and rounding chosen above +builder-account-system-hint = system column — the account each row is posted to, written as account +builder-rename-generated-hint = Each of these can be given a name of your own under Rename generated columns once the row is added; leave a field empty to keep the name shown here. +builder-reserved-hint = These names are reserved: no column of your own may use them, and the table may hold only one { $type }. They land where this row sits in the column list, and can be moved with it. +builder-columns-heading = Columns +builder-no-columns = No columns yet. Describe one above and press +builder-columns-order-hint = Columns are created in this order. Use the arrows to move one. +builder-th-order = Order +builder-th-name = Name +builder-th-type = Type +builder-th-indexed = Indexed +builder-th-options = Options +builder-move-up = Move { $name } up +builder-move-down = Move { $name } down +builder-renamed-to = renamed to { $alias } +builder-rename-generated = Rename generated columns +builder-rename-generated-hint2 = These columns come from the definition rows above. Give one a name of your own, or leave it empty to keep the name shown. +builder-rename-aria = Rename { $name } +builder-apply-names = Apply names +builder-row-display = Row display columns +builder-row-display-hint = What identifies a row to users, in the order chosen. Pick id to clear the list. +builder-preview = Table definition preview +builder-preview-th-column = Column +builder-preview-th-type = Type +builder-preview-th-options = Options +builder-preview-th-source = Source +builder-preview-primary-key = primary key +builder-preview-default-false = default false +builder-preview-not-null-1 = not null, default 1 +builder-preview-current-time = current time +builder-preview-account-option = not null, → accounts, written as { $field } +builder-option-indexed = indexed +builder-create-table = Create table +builder-status-generated-keep = The generated columns keep their own names. +builder-status-generated-one = 1 generated column renamed. +builder-status-generated-many = { $count } generated columns renamed. + +draft-err-profile-name = Enter a name for the new profile. +draft-err-table-conflict-global = A table named `{ $name }` already exists. A shared table's name has to be free in every profile. +draft-err-table-conflict = A table named `{ $name }` already exists in profile `{ $profile }`, or is shared by every profile. +draft-err-no-columns = Add at least one column before saving. +draft-err-alias-taken = Alias `{ $alias }` for generated column `{ $source }` is already taken by another column. +draft-err-accounting-currency = Accounting currency must be a three-letter ISO-4217 code + +schema-status-column-added = Column `{ $name }` added. +schema-status-column-removed = Column `{ $name }` removed. +schema-status-column-moved-up = Column `{ $name }` moved up. +schema-status-column-moved-down = Column `{ $name }` moved down. + +schema-err-both-name-type = Both a column name and a column type are required. +schema-err-creation-only = A { $type } column can only be chosen while the table is being created. +schema-err-column-exists = A column named `{ $name }` already exists. +schema-err-generated-exists = `{ $type }` generates a column named `{ $generated }`, and the table already has one. +schema-err-ql-types = Quantity-ledger columns must use { $types } +schema-err-column-ql-types = Column `{ $name }`: quantity-ledger columns must use { $types } +schema-err-column-prefix = Column `{ $name }`: { $error } +schema-err-decimal-not-valid = `decimal` is not a valid field type. +schema-err-link-not-valid = `link` is not a valid field type. +schema-err-decimal-args-needed = `{ $type }` needs both a precision and a scale. +schema-err-link-target-needed = `link` needs a referenced table. +schema-err-generated-type = `{ $type }` is a column type the backend generates itself. +schema-err-invalid-type = `{ $type }` is not a valid field type. +schema-err-account-link = The account relationship is built into ACCOUNTING and cannot be declared as a link. +schema-err-link-self = Link `{ $column }` cannot point at the table being created. +schema-err-column-gone = That column no longer exists. +schema-err-link-indexed = Link `{ $name }` is indexed automatically and cannot be indexed again. +schema-err-definition-indexed = `{ $name }` is a definition row, not a column, so it cannot be indexed. +schema-err-currency-only = Column `{ $name }`: a currency belongs only to a column type that requires one. +schema-err-name-twice = `{ $name }` names more than one of this table's columns. +schema-err-currency-code = Currency must be a three-letter ISO-4217 code +schema-err-profile-only-type = A shared table cannot use { $type }: it posts to one profile's books, and a shared table belongs to every profile. +schema-err-shared-ql = Column `{ $name }`: a shared table cannot keep a quantity ledger, which belongs to one profile. +schema-err-profile-reserved = That profile name is reserved by PostgreSQL. +schema-err-table-name-too-long = Table name cannot be longer than 63 characters, because the indexes on its columns are named after it. +schema-err-table-name-reserved = `{ $name }` is the name of a table every profile is given, so it cannot be reused. + +error-identifier-empty = { $label } cannot be empty. +error-identifier-whitespace = { $label } cannot start or end with a space. +error-identifier-underscore = { $label } cannot start with an underscore. +error-identifier-number = { $label } cannot start with a number. +error-identifier-too-long = { $label } cannot be longer than 63 characters. +error-identifier-charset = { $label } may only use lowercase letters, digits and underscores. +error-identifier-reserved = { $label } uses a reserved name. +error-decimal-required = { $label } is required for a decimal column. +error-decimal-sign = { $label } cannot carry a sign. +error-decimal-whole = { $label } must be a whole number. +error-decimal-leading-zeros = { $label } cannot have leading zeros. +error-precision-min = Precision must be at least 1. +error-scale-gt-precision = Scale cannot be greater than precision. + +# --- Add validation -------------------------------------------------------- +validation-eyebrow = Validation +validation-library-eyebrow = Global validation library +validation-field-title = Add field validation +validation-field-lead = Attach validation to one column of one table. +validation-rule-title = Add reusable validation rule +validation-rule-lead = A named rule that validation sets can compose, independent of any table. +validation-set-title = Add validation set +validation-set-lead = Compose an ordered set from existing reusable rule names. +validation-profile = Profile +validation-table = Table +validation-column = Column +validation-apply-set = Apply named validation set instead +validation-apply-set-placeholder = Leave empty to save the rules below +validation-error-title = Could not save validation +validation-success-title = Validation saved +validation-save = Save validation +validation-rule-name = Rule name +validation-description = Description +validation-save-rule = Save rule +validation-set-name = Set name +validation-reusable-rules = Reusable rules +validation-reusable-rules-hint = Comma-separated and applied in this order. +validation-save-set = Save set +validation-position-rules = Position rules +validation-position-rules-hint = One per line: position | constraint. Positions: 0, 0-3, 4+, or 1,3,5. Constraints: alphabetic, numeric, alphanumeric, exact=x, one-of=a,b, regex=… +validation-pattern-description = Pattern description +validation-min-length = Minimum length +validation-max-length = Maximum length +validation-warn-at = Warning threshold +validation-count-mode = Count mode +validation-count-chars = Characters +validation-count-bytes = Bytes +validation-count-display-width = Display width +validation-allowed-values = Allowed values +validation-display-mask = Display mask +validation-mask-input-char = Mask input character +validation-mask-template-char = Mask template character +validation-required = Required +validation-allow-empty = Allow empty +validation-case-insensitive = Case insensitive +validation-external = External validation +validation-lock = Lock configuration +validation-err-at-least-one-rule = Enter at least one reusable rule name. +validation-err-enter = Enter a { $label }. +validation-err-non-negative = { $label } must be a non-negative integer. +validation-err-min-max = Minimum cannot be greater than maximum. +validation-err-pattern-line = Pattern line { $line } must use `position | constraint`. +validation-err-range-start = Pattern range start cannot exceed its end. +validation-err-bad-position = Invalid pattern position '{ $value }'. +validation-err-bad-constraint = Unknown character constraint '{ $value }'. +validation-err-permission = Validation-management permission is required. diff --git a/web/locales/sk/main.ftl b/web/locales/sk/main.ftl index c79fd50c..f82b3e41 100644 --- a/web/locales/sk/main.ftl +++ b/web/locales/sk/main.ftl @@ -1 +1,803 @@ -# Slovak translations. Missing messages fall back to English. +# ============================================================================ +# Slovak (sk) locale. Keys must stay identical to locales/en/main.ftl. +# Only the values (right of "=") are translated. +# ============================================================================ + +# --- Spoločné / zdieľané slová --------------------------------------------- +common-cancel = Zrušiť +common-no = Nie +common-remove = Odstrániť +common-yes = Áno + +# --- Chrome stránky (ui/, navbar) ------------------------------------------ +ui-back-to-admin = Späť do administračného panela +ui-back-to-admin-panel = ← Administračný panel +ui-back-to-form = Späť do formulára +ui-dismiss-alert = zatvoriť upozornenie +notice-log-in-first = Najprv sa prosím prihláste. + +nav-admin = Administrácia +nav-permissions = Oprávnenia +nav-analytics = Analytika +nav-rates = Kurzy +nav-import = Import +nav-export = Export +nav-password = Heslo +nav-log-out = Odhlásiť sa +nav-login = Prihlásenie +nav-register = Registrácia +nav-main-menu = hlavné menu +nav-mobile-menu = mobilné menu + +# --- Názvy polí používané v poskladaných správach --------------------------- +label-profile = profil +label-table = tabuľka +label-column = stĺpec +label-set-name = názov sady +label-rule-name = názov pravidla +label-validation-set-name = názov validačnej sady +label-minimum = Minimum +label-maximum = Maximum +label-warning-threshold = Hranica upozornenia +label-precision = Presnosť +label-scale = Desatinné miesta +label-column-name = Názov stĺpca +label-table-name = Názov tabuľky +label-linked-table = Odkazovaná tabuľka +label-column-alias = Alias stĺpca +label-profile-name = Názov profilu + +# --- Odznaky stĺpcov -------------------------------------------------------- +column-flag-system = systémový +column-flag-primary-key = primárny kľúč +column-flag-nullable = môže byť prázdny +column-flag-required = povinný +column-flag-quantity-ledger = kvantitatívna evidencia +column-flag-half-up = zaokrúhlené nahor +column-flag-read-only = len na čítanie +column-flag-generated = generovaný +column-flag-generated-from = generovaný z { $source } + +# --- Prihlásenie / registrácia / zmena hesla ------------------------------- +login-title = Prihlásenie +login-username-or-email = Používateľské meno alebo e-mail +login-password = Heslo +login-optional = (voliteľné) +login-button-login = Prihlásiť sa +login-no-account = Ešte nemáte účet? +login-error-title = Prihlásenie sa nepodarilo +login-error-identifier-required = Používateľské meno alebo e-mail je povinné +login-error-invalid-token = Server vrátil neplatný prístupový token + +register-title = Registrácia +register-button-register = Registrovať sa +register-confirm-password = Potvrdiť heslo +register-have-account = Už máte účet? +register-error-title = Registrácia sa nepodarila +register-success-title = Registrácia úspešná. Teraz sa môžete prihlásiť. +register-success-message = + ID používateľa: { $id } + Používateľské meno: { $username } + E-mail: { $email } + Rola: { $role } + Časové pásmo: { $timezone } + Krajina telefónu: { $phone_country } + +field-username = Používateľské meno +field-email = E-mail +field-timezone = Časové pásmo +field-phone-country = Krajina telefónu +field-current-password = Aktuálne heslo +field-new-password = Nové heslo +field-confirm-new-password = Potvrdiť nové heslo + +password-title = Zmena hesla +password-button-change = Zmeniť heslo +password-empty-current-hint = Pri čerstvo vytvorenom účte správcu alebo super správcu nechajte aktuálne heslo prázdne. +password-error-title = Zmenu hesla sa nepodarilo vykonať +password-success-title = Heslo zmenené +password-changed-message = Vaše nové heslo je aktívne. + +# --- Administračný panel (prehliadač pracovnej plochy) ---------------------- +admin-title = Administračný panel +admin-eyebrow-workspace = Pracovná plocha +admin-description = Prechádzajte profily, tabuľky a ich fyzické stĺpce. Vyberte tabuľku a zobrazia sa možnosti jej úprav. +admin-add-table = Pridať tabuľku +admin-add-logic = Pridať logiku +admin-add-validation = Pridať validáciu +admin-add-rule = Pridať pravidlo +admin-export = Export +admin-exchange-rates = Devízové kurzy +admin-unavailable-title = Administračný panel nie je dostupný +admin-forbidden = Tento účet nemá povolenie otvoriť administračný panel. + +admin-profiles = Profily +admin-no-profiles-available = Nie sú k dispozícii žiadne profily. +admin-table-count = { $count -> + [one] { $count } tabuľka + [few] { $count } tabuľky + *[other] { $count } tabuliek +} +admin-shared-by-every-profile = zdieľaná všetkými profilmi +admin-tables-heading = Tabuľky +admin-select-profile-first = Vyberte profil, aby ste videli jeho tabuľky. +admin-no-global-tables = Neexistujú žiadne globálne tabuľky. +admin-no-profile-tables = Tento profil nemá vlastné tabuľky. +admin-no-dependencies = Bez závislostí +admin-depends-on-prefix = Závisí od +admin-row-display-prefix = zobrazenie: +admin-add-rename-columns = Pridať / premenovať stĺpce +admin-delete-table = Vymazať tabuľku +admin-who-may-use-it = Kto ju môže používať +admin-pick-table-hint = Vyberte tabuľku, aby ste do nej pridali stĺpce, vymazali ju alebo určili, kto ju môže používať. +admin-new-global-table = Nová globálna tabuľka +admin-new-table = Nová tabuľka +admin-from-template = Zo šablóny +admin-copy-profile = Kopírovať profil +admin-rename-history = História premenovaní +admin-columns = Stĺpce +admin-column-counts = { $user -> + [one] { $user } používateľ + [few] { $user } používatelia + *[other] { $user } používateľov +} · { $system -> + [one] { $system } systém + [few] { $system } systémy + *[other] { $system } systémov +} +admin-select-table-inspect = Vyberte tabuľku, aby ste zobrazili jej stĺpce. +admin-no-visible-columns = Táto tabuľka nemá žiadne viditeľné stĺpce. +admin-system-columns-label = Systémové stĺpce · skryté pred používateľmi + +# --- Stránky definície tabuľky (stĺpce / vymazať / kopírovať / šablóna / história) +td-columns-title = Stĺpce +td-add-columns-to = Pridať stĺpce do +td-append-hint = Stĺpce sa pridávajú na koniec. Nič, čo už existuje, sa nemení a nové stĺpce možno indexovať hneď pri pridaní. +td-rename-column = Premenovať stĺpec +td-rename-hint = Premenúva sa len zobrazený názov, nie fyzický stĺpec pod ním, takže uložené údaje a skripty zostávajú nedotknuté. Možné len kým tabuľka nemá žiadne riadky. +td-column-label = Stĺpec +td-choose-column = Vyberte stĺpec +td-new-name = Nový názov +td-rename-button = Premenovať stĺpec +td-columns-now = Súčasné stĺpce +td-col-column = Stĺpec +td-col-type = Typ +td-col-notes = Poznámky +td-scripts = Skripty +td-col-target-column = Cieľový stĺpec +td-col-description = Popis +td-source = zdroj + +td-could-not-add-column = Stĺpec sa nepodarilo pridať +td-staged = Pripravené +td-link-alias = Alias odkazu +td-column-name = Názov stĺpca +td-link-alias-hint = Alias je názov stĺpca, ktorým sa tento vzťah rozlišuje. +td-column-type = Typ stĺpca +td-choose-type = Vyberte typ +td-fk-link = FKlink +td-temporal-type = Časový typ +td-choose-temporal-type = Vyberte časový typ +td-gtin-type = Typ GTIN +td-choose-gtin-length = Vyberte dĺžku GTIN +td-precision = Presnosť +td-precision-hint = Celkový počet uložených číslic, aspoň 1. +td-scale = Desatinné miesta +td-scale-hint = Číslice za desatinnou čiarkou, najviac toľko ako presnosť. +td-referenced-table = Odkazovaná tabuľka +td-choose-table = Vyberte tabuľku +td-group-global = Globálne +td-group-user = Vytvorené používateľom +td-group-system = Vytvorené systémom +td-no-eligible-tables = V tomto rozsahu neexistujú žiadne vhodné tabuľky. +td-currency = Mena +td-rounding = Zaokrúhľovanie +td-rounding-none = žiadne +td-rounding-half-up = zaokrúhliť nahor +td-indexing = Indexovanie +td-indexed-automatically = Indexované automaticky — odkaz je cudzí kľúč a server indexuje každý z nich. +td-required = Povinný +td-required-hint = Riadok musí preň obsahovať hodnotu. +td-quantity-ledger = Kvantitatívna evidencia +td-ql-only = Len { $types }. +td-shared-no-quantity-ledger = Zdieľaná tabuľka nevedie vlastné účtovníctvo, takže nemôže obsahovať stĺpec kvantitatívnej evidencie. +td-stage-column = Pripraviť stĺpec +td-no-staged-columns = Zatiaľ nie sú pripravené žiadne stĺpce. Popíšte jeden vyššie a stlačte +td-col-name = Názov +td-col-indexed = Indexovaný +td-col-options = Možnosti +td-indexed-automatically-tag = indexované automaticky +td-money-exact = presne +td-money-half-up = zaokrúhlené nahor +td-add-columns-button = { $count -> + [one] Pridať { $count } stĺpec do tabuľky + [few] Pridať { $count } stĺpce do tabuľky + *[other] Pridať { $count } stĺpcov do tabuľky +} + +td-eyebrow-table-definition = Definícia tabuľky +td-columns-count = { $count -> + [one] { $count } stĺpec + [few] { $count } stĺpce + *[other] { $count } stĺpcov +} +td-identified-by = identifikovaná podľa +td-its-id = svojho id +td-depends-on-prefix = závisí od +td-acts-on-profile = Všetko na tejto stránke sa týka tohto profilu. +td-global-label = Globálne — všetky profily +td-tab-aria = akcie s tabuľkou +td-tab-columns = Stĺpce +td-tab-columns-sub = Pridať stĺpce a premenovať ich +td-tab-delete = Vymazať +td-tab-delete-sub = Odstrániť túto tabuľku +td-tab-access = Prístup +td-tab-access-sub = Ktoré roly ju môžu čítať a zapisovať +td-tab-copy = Kopírovať profil +td-tab-copy-sub = Duplikovať jeho štruktúru +td-tab-template = Zo šablóny +td-tab-template-sub = Vytvoriť tabuľky z Typst faktúry +td-tab-history = História premenovaní +td-tab-history-sub = Ako stĺpce získali svoje názvy +td-backend-managed-title = Spravované backendom +td-backend-managed-hint = Táto tabuľka patrí backendu. Jej definíciu si môžete pozrieť v administračnom paneli, ale meniť sa dá len cez API backendu, takže žiadna z akcií na zápis sa na ňu nevzťahuje. + +td-could-not-continue = Nedá sa pokračovať +td-done = Hotovo +td-sql-ran = SQL, ktorý backend vykonal +td-delete-title = Vymazať tabuľku +td-delete-table = Vymazať tabuľku +td-delete-hint = Odstráni tabuľku aj jej definíciu, a ak to bola posledná tabuľka profilu, aj profil. Backend odmietne vymazať tabuľku, ktorá má stále riadky. +td-links-to = Odkazuje na { $tables }. +td-type-to-confirm = Napíšte +td-no-table-chosen = Nebola vybratá žiadna tabuľka +td-pick-table-delete = Najprv vyberte tabuľku na vymazanie v administračnom paneli. +td-copy-title = Kopírovať profil +td-copy-into = Kopírovať +td-copy-hint = Kopíruje štruktúru — tabuľky, odkazy a skripty — bez riadkov. Ak nezačiarknete žiadnu tabuľku, skopíruje sa celý profil. +td-new-profile-name = Názov nového profilu +td-copy-button = Kopírovať profil +td-template-title = Vytvoriť zo šablóny +td-template-created-tables = Tabuľky vytvorené zo šablóny +td-col-table = Tabuľka +td-col-collection = Kolekcia +td-col-parent = Nadradená +td-root = koreň +td-template-create-heading = Vytvoriť tabuľky z faktúrovej šablóny +td-template-hint = Načíta kontrakt #let komp_ac_fields = (…) z Typst šablóny. Cesta, ktorá zodpovedá existujúcej tabuľke a stĺpcu, sa stane odkazom na ňu; všetko ostatné sa stane stĺpcom TEXT, ktorý neskôr upravíte, a každé [] sa stane podradenou tabuľkou. +td-table-name = Názov tabuľky +td-row-display-columns = Stĺpce identifikujúce riadok +td-row-display-hint = Oddeľte čiarkami, platí len pre koreňovú tabuľku. +td-template-source = Zdroj šablóny +td-template-create-button = Vytvoriť zo šablóny +td-history-title = História premenovaní +td-history-heading = História premenovaní stĺpcov +td-history-empty = V { $scope } nebol premenovaný žiadny stĺpec. +td-history-empty-this-profile = V tomto profile nebol premenovaný žiadny stĺpec. +td-col-was = Bol +td-col-is = Je +td-col-when = Kedy + +td-columns-added = { $count -> + [one] { $count } stĺpec pridaný do `{ $table }`. + [few] { $count } stĺpce pridané do `{ $table }`. + *[other] { $count } stĺpcov pridaných do `{ $table }`. +} +td-profile-copied = { $message } — skopírované { $tables -> + [one] { $tables } tabuľka + [few] { $tables } tabuľky + *[other] { $tables } tabuliek +} a { $scripts -> + [one] { $scripts } skript + [few] { $scripts } skripty + *[other] { $scripts } skriptov +}. +td-template-tables-created = { $count -> + [one] Vytvorená { $count } tabuľka zo šablóny. + [few] Vytvorené { $count } tabuľky zo šablóny. + *[other] Vytvorených { $count } tabuliek zo šablóny. +} +td-err-select-table-first = Najprv vyberte tabuľku. +td-err-profile-gone = Profil `{ $missing }` už neexistuje. +td-err-not-a-table = `{ $missing }` nie je tabuľka rozsahu { $scope }. +td-err-describe-column = Pred pridaním popíšte aspoň jeden stĺpec. +td-err-backend-no-columns = Backend nepridal stĺpce. +td-err-choose-rename = Vyberte stĺpec a zadajte jeho nový názov. +td-err-backend-no-rename = Backend nepremenoval stĺpec. +td-err-type-name = Na potvrdenie vymazania napíšte presný názov tabuľky. +td-err-backend-no-delete = Backend nevymazal tabuľku. +td-err-name-profile = Zadajte názov profilu, do ktorého sa kopíruje. +td-err-backend-no-copy = Backend neskopíroval profil. +td-err-template-required = Vyžaduje sa názov tabuľky aj zdroj šablóny. +td-err-backend-no-template = Backend nevytvoril tabuľky zo šablóny. +td-err-permission = Vyžaduje sa oprávnenie na správu tabuliek. +td-unavailable-title = Definícia tabuľky nie je dostupná + +# --- Referenčné kurzy ECB --------------------------------------------------- +ecb-title = Devízové kurzy +ecb-heading = Referenčné kurzy ECB +ecb-description = Importér načítava zverejnené kurzy ECB raz denne a zaznamenáva každý pokus. Konverzie čítajú len to, čo overil, takže pokrytie rozhoduje o tom, či je možné cudzomenovú sumu zahlasovať vôbec. +ecb-unavailable-title = Devízové kurzy nie sú dostupné +ecb-forbidden = Na otvorenie tejto stránky sa vyžaduje čítanie údajov o devízových kurzoch. +ecb-import-attempts = Pokusy o import +ecb-failed-count = { $count -> + [one] { $count } zlyhaný + [few] { $count } zlyhané + *[other] { $count } zlyhaných +} +ecb-no-attempts-hint = Importér nezaznamenal žiadny pokus. Beží pri štarte a potom denne, takže prázdny záznam znamená, že server od vytvorenia tejto tabuľky nedokončil žiadny beh. +ecb-th-result = Výsledok +ecb-th-started = Spustené +ecb-th-finished = Dokončené +ecb-th-took = Trvalo +ecb-th-window = Obdobie +ecb-th-observations = Pozorovania +ecb-th-verified-through = Overené do +ecb-batch-succeeded = úspešné +ecb-batch-running = prebieha +ecb-batch-failed = zlyhané +ecb-still-running = stále beží +ecb-audit-hint = Pokusy sú audítorský záznam: nemožno ich upravovať ani mazať a dávka, ktorá znovu načítala deň, ktorý už vlastnila staršia, nevloží nič — preto môže byť nový počet nižší ako celkový. +ecb-import-running = import prebieha +ecb-postable-lead = Čo možno zahlasovať práve teraz +ecb-ordinary-transactions = Bežné transakcie +ecb-statements-title = Výpisy a rozhodujúce dátumy +ecb-dated-up-to = datované do vrátane +ecb-ordinary-rate-hint = Prepočítané kurzom zverejneným v pracovný deň pred transakciou. +ecb-statements-rate-hint = Prepočítané kurzom zverejneným v ten deň, alebo v posledný pracovný deň pred ním. +ecb-postable-limit-hint = Cudzomenová suma datovaná po svojom limite sa odmieta priamo, kým importér nedobehne — neprepočítava sa zastaraným kurzom. +ecb-nothing = Nič. +ecb-no-publication-hint = Nikdy nebolo overené žiadne zverejnenie, takže každá cudzomenová suma sa odmieta bez ohľadu na dátum. +ecb-verified-through = Overené do +ecb-never = nikdy +ecb-up-to-date = aktuálne +ecb-behind-count = { $count -> + [one] { $count } zmeškaný publikačný deň + [few] { $count } zmeškané publikačné dni + *[other] { $count } zmeškaných publikačných dní +} +ecb-no-coverage = bez pokrytia +ecb-should-reach = Má dosiahnuť +ecb-last-attempt = Posledný pokus +ecb-none-recorded = žiadny nezaznamenaný +ecb-last-success = Posledný úspech +ecb-none = žiadny +ecb-next-import = Ďalší import +ecb-currencies-on = Meny k { $date } +ecb-the-covered-day = pokrytý deň +ecb-most-recent-failure = Posledné zlyhanie — dávka { $batch }, spustené { $started }: +ecb-no-reason-recorded = nebol zaznamenaný žiadny dôvod. +ecb-headline-current = Kurzy sú aktuálne +ecb-headline-never-ran = Nikdy neboli importované žiadne kurzy +ecb-headline-behind = Kurzy meškajú +ecb-explanation-current = Overený je každý publikačný deň až po najnovší, takže konverzie sa hlasujú normálne. +ecb-explanation-never-ran = Kým sa jeden nepodarí, každá konverzia v cudzej mene sa odmieta pre nedostatok pokrytia. +ecb-explanation-behind = { $count -> + [one] Chýba { $count } publikačný deň. + [few] Chýbajú { $count } publikačné dni. + *[other] Chýba { $count } publikačných dní. +} Konverzia datovaná do medzery sa odmieta, kým importér nedobehne. +ecb-new-of = { $inserted } nových z { $total } +ecb-in = o { $time } +ecb-ago = pred { $time } +ecb-less-than-1s = <1s + +# --- Oprávnenia (roly / používatelia / prístup) ------------------------------ +perm-eyebrow = Oprávnenia +perm-tabs-aria = sekcie oprávnení +perm-tab-roles = Roly +perm-tab-roles-sub = Vytváranie rolí a ich dedičnosti +perm-tab-people = Ľudia +perm-tab-people-sub = Priradiť používateľovi rolu +perm-tab-access = Prístup +perm-tab-access-sub = Čo rola môže robiť +perm-unavailable-title = Oprávnenia nie sú dostupné +perm-update-error = Oprávnenia sa nepodarilo aktualizovať +perm-forbidden = Tento účet nespravuje ani roly, ani používateľov. +perm-err-missing-session = Chýbajúca relácia +perm-err-nothing-to-change = Nie je čo meniť: formulár neobsahoval žiadne oprávnenia. +perm-err-bad-pair = '{ $value }' nie je dvojica objekt|akcia. +perm-err-not-editable-role = '{ $role }' nie je rola, ktorú môžete upravovať. +perm-err-neither-grant-nor-revoke = '{ $value }' nie je ani grant, ani zrušenie. +perm-err-grant-failed = Udeľovanie { $action } na { $object } zlyhalo: { $error } +perm-err-revoke-failed = Rušenie { $action } na { $object } zlyhalo: { $error } +perm-err-bad-starter-access = '{ $value }' nie je úroveň štartovacieho prístupu. +perm-role-created-grant-failed = Rola '{ $role }' bola vytvorená, ale udeľovanie { $action } na { $object } zlyhalo: { $error } +perm-role-reason-outranks-you = má vyššiu úroveň ako vy +perm-role-reason-built-in = vstavaná +perm-role-reason-still-assigned = stále priradená + +roles-description = Rola je pomenovaná množina oprávnení. Ľuďom sa prideľujú roly; rolam sa prideľuje prístup. +roles-updated-notice = Roly aktualizované. Prihlásené relácie zostávajú platné a zmenu zachytia pri najbližšej požiadavke. +roles-new-heading = Nová rola +roles-new-hint = Dedičnosť z inej roly dá novej role všetko, čo má tá rola, a udržiava ju v súlade, keď sa tá rola zmení. Štartovací prístup je skratka pre dva bežné prípady; čokoľvek užšie je pár klikov na karte Prístup. +roles-name = Názov +roles-inherits-from = Dedi z +roles-inherits-none = Nič — začína prázdna +roles-starter-access = Štartovací prístup +roles-access-none = Žiadny — udeľte ho na karte Prístup +roles-access-read = Čítať všetko +roles-access-full = Čítať a zapisovať všetko +roles-create-button = Vytvoriť rolu +roles-existing = Existujúce roly +roles-th-role = Rola +roles-th-kind = Druh +roles-th-inherits = Dedi z +roles-th-people = Ľudia +roles-th-access = Prístup +roles-th-remove = Odstrániť +roles-built-in = vstavaná +roles-edit-access = Upraviť prístup + +users-description = Priraďte používateľovi rolu. Čo rola môže robiť, sa určí raz, na karte Prístup. +users-updated-notice = Aktualizované. Najbližšia požiadavka danej osoby použije novú rolu; nemusí sa znova prihlasovať. +users-heading = Používatelia +users-outrank-hint = Môžete meniť len niekoho, koho rola je nižšia ako vaša — tu { $editable } z { $total }. +users-th-user = Používateľ +users-th-email = E-mail +users-th-role = Rola +users-th-change = Zmeniť rolu +users-th-reset = Obnoviť heslo +users-assign = Priradiť +users-confirm = Potvrdiť +users-reset = Obnoviť + +grants-description = Čo rola môže robiť s údajmi. Každý, kto rolu drží, dostane presne toto. +grants-updated-notice = Prístup aktualizovaný. Platí od najbližšej požiadavky každého držiteľa roly. +grants-no-role = Nie je čo upravovať +grants-no-role-hint = Nie je tu žiadna rola s nižšou úrovňou ako vaša. Vytvorte jednu najprv. +grants-role-heading = Rola +grants-editing = Upravujete +grants-inherits-prefix = → dedí +grants-inherits-parent = { $role } dedí všetko, čo má { $parent }. Dedený prístup sa zobrazuje nižšie, ale mení sa na nadradenej role. +grants-inherits-none = { $role } nededí nič, takže všetko, čo vidíte nižšie, je všetko, čo má. +grants-structural-hint = Táto rola navrhuje systém a nikdy nemôže zapisovať riadkové údaje, takže je otvorený len stĺpec na čítanie. +grants-shortcuts = Skratky +grants-shortcuts-hint = Udeľujú zástupné objekty, takže naďalej pokrývajú profily a tabuľky vytvorené neskôr. +grants-full-access-everything = Úplný prístup ku všetkému +grants-remove-all-access = Odstrániť všetok prístup +grants-everything-everywhere = Všetko, všade +grants-profile-prefix = Profil: +grants-read-only = Iba na čítanie +grants-full-access = Úplný prístup +grants-clear = Vyčistiť +grants-th-object = Objekt +grants-th-row = Riadok +grants-cell-unavailable = Pre túto rolu nie je dostupné +grants-cell-direct = Drží priamo — kliknutím zrušíte +grants-cell-inherited = zdedené: pokrýva to nadradená rola alebo širší grant +grants-cell-grant = Kliknutím udelíte +grants-all = Všetko +grants-none = Nič +grants-read-everything-hint = Čítanie na každom profile, denníku a devízovom kurze +grants-full-everything-hint = Každá akcia, ktorú rola môže držať, na každom profile +grants-remove-all-hint = Odvolať každý grant, ktorý táto rola drží priamo +grants-read-only-hint = Čítanie všetkého tu +grants-full-access-hint = Každá akcia, ktorú rola môže držať, na všetkom tu +grants-clear-hint = Odvolať všetko, čo táto rola drží priamo tu +grants-all-hint = Udeliť každú akciu dostupnú na tomto objekte +grants-none-hint = Odvolať priame granty tohto objektu +grants-everything-title = Všetko +grants-label-all-tables = Všetky tabuľky +grants-note-all-tables = Každá tabuľka v každom profile vrátane tých, ktoré pribudnú neskôr. +grants-label-all-journals = Všetky denníky +grants-note-all-journals = Účtovný denník každého profilu. +grants-label-all-rates = Všetky devízové kurzy +grants-note-all-rates = Kurzy ECB importuje server, takže ich možno len čítať. +grants-note-profile-tables = Každá tabuľka v tomto profile vrátane tých, ktoré pribudnú neskôr. +grants-label-journal = Denník +grants-note-journal = Účtovný denník tohto profilu. +grants-label-rates = Devízové kurzy +grants-note-rates = Importuje server, takže jediným grantom je čítanie. +grants-note-table = Táto tabuľka a celá jej šablónová rodina. + +# --- Analytika --------------------------------------------------------------- +analytics-title = Analytika +analytics-eyebrow = Reportovanie +analytics-description = Dotazujte sa na read-only analytické API a výsledok zobrazte v grafe. +analytics-available-data = Dostupné údaje +analytics-load-hint = Načítajte profil, aby ste videli aliasy tabuliek a stĺpcov, ktoré analytický SQL akceptuje. +analytics-profile = Profil +analytics-loading-profiles = Načítavajú sa profily… +analytics-load-schema = Načítať schému +analytics-no-schema = Nie je načítaná žiadna schéma. +analytics-chart = Graf +analytics-chart-bar = Stĺpcový +analytics-chart-line = Čiarový +analytics-chart-pie = Koláčový +analytics-chart-scatter = Bodový +analytics-chart-table = Tabuľka +analytics-max-rows = Maximálny počet riadkov +analytics-sql = SQL +analytics-sql-placeholder = Načítajte schému a potom vyberte štartovací dotaz alebo napíšte SELECT dotaz +analytics-run-query = Spustiť dotaz +analytics-running = Spúšťa sa… +analytics-no-tables = Tento profil nemá analytické tabuľky. +analytics-tables-available = { $count -> + [one] k dispozícii { $count } tabuľka + [few] k dispozícii { $count } tabuľky + *[other] k dispozícii { $count } tabuliek +} +analytics-use-starter = Použiť štartovací dotaz +analytics-links = Odkazy +analytics-required = povinné +analytics-llm-context = Kontext schémy pre LLM +analytics-llm-hint = Skopírujte celý tento text a zahrňte ho, keď požiadate LLM o napísanie dotazu. +analytics-copy-context = Kopírovať kontext +analytics-copied = Skopírované +analytics-load-error = Profily sa nepodarilo načítať: { $message } +analytics-no-profiles = Nie sú k dispozícii žiadne profily +analytics-choose-profile = Vyberte profil… +analytics-sign-in = Prihláste sa, aby ste videli profily, ktoré môžete čítať. +analytics-err-profile-required = Zadajte názov profilu na načítanie jeho schémy +analytics-err-input-required = Vyžadujú sa názov profilu a SQL +analytics-err-chart-columns = Grafy vyžadujú aspoň dva výsledné stĺpce +analytics-err-chart-type = Neznámy typ grafu +analytics-not-signed-in = Nie ste prihlásený na { $action }. +analytics-action-loading-schema = načítavanie schémy +analytics-action-running-query = spúšťanie dotazu +analytics-result-meta = { $rows -> + [one] { $rows } riadok za { $elapsed } ms + [few] { $rows } riadky za { $elapsed } ms + *[other] { $rows } riadkov za { $elapsed } ms +} +analytics-result-meta-truncated = { $rows -> + [one] { $rows } riadok za { $elapsed } ms (skrátené) + [few] { $rows } riadky za { $elapsed } ms (skrátené) + *[other] { $rows } riadkov za { $elapsed } ms (skrátené) +} + +# --- Import / export --------------------------------------------------------- +transfer-eyebrow = Prenos údajov +import-title = Import CSV +import-lead = Vyberte súbor v prehliadači alebo vložte CSV. Riadky sa overujú proti živým štruktúram tabuliek pred hromadným vkladaním cez gRPC. +import-profile = Profil +import-target-tables = Cieľové tabuľky +import-csv-file = Súbor CSV +import-csv-data = Údaje CSV +import-error-title = CSV sa nepodarilo importovať +import-import-rows = Importovať riadky +import-success-title = Import dokončený +import-success-message = Vložené { $inserted -> + [one] { $inserted } záznam + [few] { $inserted } záznamy + *[other] { $inserted } záznamov +} z { $source_rows -> + [one] { $source_rows } riadku CSV + [few] { $source_rows } riadkov CSV + *[other] { $source_rows } riadkov CSV +} v { $table_count -> + [one] { $table_count } tabuľke + [few] { $table_count } tabuľkách + *[other] { $table_count } tabuľkách +}. +import-err-select-profile = Vyberte profil. +import-err-tables-required = Zadajte aspoň jednu cieľovú tabuľku. +import-err-csv-required = Vyberte súbor CSV alebo vložte údaje CSV. +import-err-unknown-profile = Neznámy profil. +import-err-tables-not-in-profile = Jedna alebo viac cieľových tabuliek nepatrí do profilu. +import-err-missing-structure = Backend vynechal požadovanú štruktúru tabuľky. +import-err-no-importable-columns = CSV nemá importovateľné stĺpce pre tabuľku '{ $table }'. +import-err-column-not-importable = Stĺpec '{ $column }' nie je importovateľný pre tabuľku '{ $table }'. +import-err-csv-row = Riadok CSV { $row }: { $error } +import-err-multi-headers = Viac-tabuľkové CSV potrebuje riadok s hlavičkou tabuliek a riadok s hlavičkou stĺpcov. +import-err-first-row = Prvý riadok CSV musí obsahovať len vybrané názvy tabuliek. +import-err-header-lengths = Riadok hlavičky tabuliek a stĺpcov majú rôznu dĺžku. +import-err-row-width = Riadok údajov CSV má iný počet polí ako hlavička. +import-err-no-data-rows = CSV obsahuje hlavičky, ale žiadne riadky údajov. +import-err-missing-type = Chýba typ stĺpca '{ $column }' +import-err-permission = Vyžaduje sa oprávnenie na vkladanie pre aspoň jednu čitateľnú tabuľku. +import-err-unterminated-quote = CSV obsahuje neuzavretú úvodzovkovú hodnotu. +import-err-empty = CSV je prázdny. +import-err-bad-boolean = Neplatná boolovská hodnota '{ $value }' +import-err-bad-integer = Neplatná celočíselná hodnota '{ $value }' + +export-title = Export CSV +export-lead = Súbor sa generuje z živých údajov tabuliek cez gRPC. +export-profile = Profil +export-choose-profile = Vyberte profil +export-tables = Tabuľky +export-comma-hint = Viacero tabuliek oddeľte čiarkami. +export-error-title = CSV sa nepodarilo exportovať +export-download = Stiahnuť CSV +export-err-select-profile = Vyberte profil. +export-err-tables-required = Zadajte aspoň jednu tabuľku. +export-err-unknown-profile = Neznámy profil. +export-err-tables-not-in-profile = Jedna alebo viac tabuliek nepatrí do vybraného profilu. +export-err-missing-structure = Backend vynechal požadovanú štruktúru tabuľky. +export-err-negative-count = Backend vrátil záporný počet riadkov. +export-err-range = Export presahuje podporovaný rozsah pozícií riadkov. +export-err-permission = Vyžaduje sa oprávnenie na čítanie pre aspoň jednu tabuľku. + +# --- Pridať logiku ------------------------------------------------------------ +add-logic-title = Pridať logiku +add-logic-eyebrow = Vypočítaný stĺpec +add-logic-lead = Vytvorte alebo aktualizujte Steel skript cez existujúcu službu skriptov tabuliek. +add-logic-table = Tabuľka +add-logic-choose-table = Vyberte tabuľku +add-logic-name = Názov logiky +add-logic-target-column = Cieľový stĺpec +add-logic-script = Steel výraz +add-logic-description = Popis +add-logic-error-title = Logiku sa nepodarilo uložiť +add-logic-save = Uložiť logiku +add-logic-success-title = Logika uložená +add-logic-saved = Skript #{ $id } bol uložený. +add-logic-saved-warnings = Skript #{ $id } bol uložený. Upozornenia: { $warnings } +add-logic-err-select-table = Vyberte cieľovú tabuľku. +add-logic-err-target-column = Zadajte cieľový stĺpec. +add-logic-err-script = Zadajte Steel výraz. +add-logic-err-script-paren = Steel výraz musí začínať `(`. +add-logic-err-permission = Vyžaduje sa oprávnenie na správu skriptov. + +# --- Nástroj na pridávanie tabuľky -------------------------------------------- +add-table-title = Pridať tabuľku +add-table-eyebrow = Definícia tabuľky +add-table-lead = Vyberte, či tabuľka patrí jednému profilu alebo je dostupná všetkým profilom, a potom pridajte jej stĺpce. Po vytvorení môžete priradiť počiatočné oprávnenia rolám. +builder-err-title = Tabuľku sa nepodarilo vytvoriť +builder-err-permission = Vyžaduje sa oprávnenie na správu tabuliek. +builder-err-backend-no-create = Backend nevytvoril tabuľku. +builder-could-not-continue = Nedá sa pokračovať +builder-draft-updated = Návrh aktualizovaný +builder-table-details = Podrobnosti tabuľky +builder-scope-question = Kde má byť táto tabuľka dostupná? +builder-scope-profile = Jeden profil +builder-scope-profile-hint = Túto tabuľku môže používať len vybraný profil. +builder-scope-global = Všetky profily +builder-scope-global-hint = Túto zdieľanú tabuľku môže používať každý profil. +builder-which-profile = Ktorý profil? +builder-choose-profile = Vyberte profil +builder-new-profile = Nový profil… +builder-new-profile-name = Názov nového profilu +builder-accounting-currency = Účtovná mena +builder-table-name = Názov tabuľky +builder-add-column-heading = Pridať stĺpec +builder-add-column-button = Pridať stĺpec +builder-shared-table-hint = Zdieľaná tabuľka nevedie vlastné účtovníctvo, takže stĺpce ACCOUNTING, ACCOUNTING_TRANSFER a kvantitatívnej evidencie sa tu neponúkajú — každý z nich sa účtuje do denníka jedného profilu. +builder-compound-intro = je definičný riadok, nie vlastný stĺpec. Jeho pridaním tabuľka získa: +builder-inherits-currency-hint = držané v mene a zaokrúhlení zvolených vyššie +builder-account-system-hint = systémový stĺpec — účet, na ktorý sa účtuje každý riadok, zapisovaný ako account +builder-rename-generated-hint = Každému z nich môžete dať vlastný názov v časti Premenovať generované stĺpce hneď po pridaní riadku; prázdne pole ponechá názov zobrazený tu. +builder-reserved-hint = Tieto názvy sú vyhradené: žiadny vlastný stĺpec ich nesmie použiť a tabuľka môže obsahovať len jeden { $type }. Umiestnia sa na miesto tohto riadku v zozname stĺpcov a dajú sa s ním presúvať. +builder-columns-heading = Stĺpce +builder-no-columns = Zatiaľ žiadne stĺpce. Popíšte jeden vyššie a stlačte +builder-columns-order-hint = Stĺpce sa vytvárajú v tomto poradí. Šípkami môžete jeden presunúť. +builder-th-order = Poradie +builder-th-name = Názov +builder-th-type = Typ +builder-th-indexed = Indexovaný +builder-th-options = Možnosti +builder-move-up = Posunúť { $name } hore +builder-move-down = Posunúť { $name } dole +builder-renamed-to = premenovaný na { $alias } +builder-rename-generated = Premenovať generované stĺpce +builder-rename-generated-hint2 = Tieto stĺpce pochádzajú z definičných riadkov vyššie. Dajte niektorému vlastný názov, alebo ho nechajte prázdny, aby zostal zobrazený názov. +builder-rename-aria = Premenovať { $name } +builder-apply-names = Použiť názvy +builder-row-display = Stĺpce identifikujúce riadok +builder-row-display-hint = Čo používateľom identifikuje riadok, v zvolenom poradí. Výberom id sa zoznam vyprázdni. +builder-preview = Náhľad definície tabuľky +builder-preview-th-column = Stĺpec +builder-preview-th-type = Typ +builder-preview-th-options = Možnosti +builder-preview-th-source = Zdroj +builder-preview-primary-key = primárny kľúč +builder-preview-default-false = predvolene false +builder-preview-not-null-1 = not null, predvolene 1 +builder-preview-current-time = aktuálny čas +builder-preview-account-option = not null, → účty, zapisované ako { $field } +builder-option-indexed = indexovaný +builder-create-table = Vytvoriť tabuľku +builder-status-generated-keep = Generované stĺpce si ponechávajú svoje názvy. +builder-status-generated-one = Premenovaný 1 generovaný stĺpec. +builder-status-generated-many = { $count -> + [one] Premenovaný { $count } generovaný stĺpec + [few] Premenované { $count } generované stĺpce + *[other] Premenovaných { $count } generovaných stĺpcov +}. + +draft-err-profile-name = Zadajte názov nového profilu. +draft-err-table-conflict-global = Tabuľka s názvom `{ $name }` už existuje. Názov zdieľanej tabuľky musí byť voľný v každom profile. +draft-err-table-conflict = Tabuľka s názvom `{ $name }` už existuje v profile `{ $profile }`, alebo je zdieľaná všetkými profilmi. +draft-err-no-columns = Pred uložením pridajte aspoň jeden stĺpec. +draft-err-alias-taken = Alias `{ $alias }` pre generovaný stĺpec `{ $source }` už používa iný stĺpec. +draft-err-accounting-currency = Účtovná mena musí byť trojpísmenový kód ISO-4217 + +schema-status-column-added = Stĺpec `{ $name }` pridaný. +schema-status-column-removed = Stĺpec `{ $name }` odstránený. +schema-status-column-moved-up = Stĺpec `{ $name }` posunutý hore. +schema-status-column-moved-down = Stĺpec `{ $name }` posunutý dole. + +schema-err-both-name-type = Vyžaduje sa názov stĺpca aj typ stĺpca. +schema-err-creation-only = Stĺpec typu { $type } možno vybrať len pri vytváraní tabuľky. +schema-err-column-exists = Stĺpec s názvom `{ $name }` už existuje. +schema-err-generated-exists = `{ $type }` generuje stĺpec s názvom `{ $generated }` a tabuľka ho už má. +schema-err-ql-types = Stĺpce kvantitatívnej evidencie musia používať { $types } +schema-err-column-ql-types = Stĺpec `{ $name }`: stĺpce kvantitatívnej evidencie musia používať { $types } +schema-err-column-prefix = Stĺpec `{ $name }`: { $error } +schema-err-decimal-not-valid = `decimal` nie je platný typ stĺpca. +schema-err-link-not-valid = `link` nie je platný typ stĺpca. +schema-err-decimal-args-needed = `{ $type }` vyžaduje presnosť aj desatinné miesta. +schema-err-link-target-needed = `link` vyžaduje odkazovanú tabuľku. +schema-err-generated-type = `{ $type }` je typ stĺpca, ktorý generuje backend. +schema-err-invalid-type = `{ $type }` nie je platný typ stĺpca. +schema-err-account-link = Vzťah účtu je zabudovaný do ACCOUNTING a nemožno ho deklarovať ako odkaz. +schema-err-link-self = Odkaz `{ $column }` nemôže ukazovať na vytváranú tabuľku. +schema-err-column-gone = Tento stĺpec už neexistuje. +schema-err-link-indexed = Odkaz `{ $name }` je indexovaný automaticky a nemožno ho indexovať znova. +schema-err-definition-indexed = `{ $name }` je definičný riadok, nie stĺpec, takže ho nemožno indexovať. +schema-err-currency-only = Stĺpec `{ $name }`: mena patrí len typu stĺpca, ktorý ju vyžaduje. +schema-err-name-twice = `{ $name }` pomenúva viac ako jeden stĺpec tejto tabuľky. +schema-err-currency-code = Mena musí byť trojpísmenový kód ISO-4217 +schema-err-profile-only-type = Zdieľaná tabuľka nemôže používať { $type }: účtuje do denníka jedného profilu a zdieľaná tabuľka patrí každému profilu. +schema-err-shared-ql = Stĺpec `{ $name }`: zdieľaná tabuľka nemôže viesť kvantitatívnu evidenciu, ktorá patrí jednému profilu. +schema-err-profile-reserved = Tento názov profilu je vyhradený PostgreSQL. +schema-err-table-name-too-long = Názov tabuľky nemôže byť dlhší ako 63 znakov, pretože indexy jej stĺpcov sú pomenované po nej. +schema-err-table-name-reserved = `{ $name }` je názov tabuľky, ktorú dostane každý profil, takže ho nemožno použiť. + +error-identifier-empty = { $label } nemôže byť prázdne. +error-identifier-whitespace = { $label } nesmie začínať ani končiť medzerou. +error-identifier-underscore = { $label } nesmie začínať podčiarkovníkom. +error-identifier-number = { $label } nesmie začínať číslom. +error-identifier-too-long = { $label } nesmie byť dlhšie ako 63 znakov. +error-identifier-charset = { $label } môže obsahovať len malé písmená, číslice a podčiarkovník. +error-identifier-reserved = { $label } používa vyhradený názov. +error-decimal-required = { $label } je povinné pre desatinný stĺpec. +error-decimal-sign = { $label } nesmie obsahovať znamienko. +error-decimal-whole = { $label } musí byť celé číslo. +error-decimal-leading-zeros = { $label } nesmie mať vedúce nuly. +error-precision-min = Presnosť musí byť aspoň 1. +error-scale-gt-precision = Desatinné miesta nemôžu byť viac ako presnosť. + +# --- Pridať validáciu ---------------------------------------------------------- +validation-eyebrow = Validácia +validation-library-eyebrow = Globálna knižnica validácií +validation-field-title = Pridať validáciu poľa +validation-field-lead = Pripojte validáciu k jednému stĺpcu jednej tabuľky. +validation-rule-title = Pridať opakovane použiteľné validačné pravidlo +validation-rule-lead = Pomenované pravidlo, z ktorého môžu validačné sady skladať, nezávislé od akejkoľvek tabuľky. +validation-set-title = Pridať validačnú sadu +validation-set-lead = Zostavte usporiadanú sadu z existujúcich názvov opakovane použiteľných pravidiel. +validation-profile = Profil +validation-table = Tabuľka +validation-column = Stĺpec +validation-apply-set = Namiesto toho použiť pomenovanú validačnú sadu +validation-apply-set-placeholder = Nechajte prázdne, aby sa uložili pravidlá nižšie +validation-error-title = Validáciu sa nepodarilo uložiť +validation-success-title = Validácia uložená +validation-save = Uložiť validáciu +validation-rule-name = Názov pravidla +validation-description = Popis +validation-save-rule = Uložiť pravidlo +validation-set-name = Názov sady +validation-reusable-rules = Opakovane použiteľné pravidlá +validation-reusable-rules-hint = Oddeľte čiarkami a aplikujú sa v tomto poradí. +validation-save-set = Uložiť sadu +validation-position-rules = Pravidlá pozícií +validation-position-rules-hint = Jeden na riadok: pozícia | obmedzenie. Pozície: 0, 0-3, 4+ alebo 1,3,5. Obmedzenia: alphabetic, numeric, alphanumeric, exact=x, one-of=a,b, regex=… +validation-pattern-description = Popis vzoru +validation-min-length = Minimálna dĺžka +validation-max-length = Maximálna dĺžka +validation-warn-at = Hranica upozornenia +validation-count-mode = Režim počítania +validation-count-chars = Znaky +validation-count-bytes = Bajty +validation-count-display-width = Zobrazovaná šírka +validation-allowed-values = Povolené hodnoty +validation-display-mask = Zobrazovacia maska +validation-mask-input-char = Vstupný znak masky +validation-mask-template-char = Šablónový znak masky +validation-required = Povinné +validation-allow-empty = Povoliť prázdne +validation-case-insensitive = Nerozlišovať veľkosť písmen +validation-external = Externá validácia +validation-lock = Zamknúť konfiguráciu +validation-err-at-least-one-rule = Zadajte aspoň jeden názov opakovane použiteľného pravidla. +validation-err-enter = Zadajte { $label }. +validation-err-non-negative = { $label } musí byť nezáporné celé číslo. +validation-err-min-max = Minimum nemôže byť väčšie ako maximum. +validation-err-pattern-line = Riadok vzoru { $line } musí používať `pozícia | obmedzenie`. +validation-err-range-start = Začiatok rozsahu vzoru nemôže presiahnuť jeho koniec. +validation-err-bad-position = Neplatná pozícia vzoru '{ $value }'. +validation-err-bad-constraint = Neznáme obmedzenie znakov '{ $value }'. +validation-err-permission = Vyžaduje sa oprávnenie na správu validácií. diff --git a/web/src/pages/add_logic/logic.rs b/web/src/pages/add_logic/logic.rs index 5f8864da..5f0007b3 100644 --- a/web/src/pages/add_logic/logic.rs +++ b/web/src/pages/add_logic/logic.rs @@ -5,7 +5,11 @@ use axum::{ response::{Html, IntoResponse, Redirect, Response}, }; -use crate::{AppState, services::authenticated_request}; +use crate::{ + AppState, + {i18n::Locale, tr}, + services::authenticated_request, +}; use super::{ loader::{LoadError, load_page}, @@ -17,7 +21,7 @@ pub(crate) async fn new_logic_page( State(state): State, headers: HeaderMap, ) -> Response { - render_loaded(load_page(state, &headers, CreateLogicForm::default(), None).await) + render_loaded(&headers, load_page(state, &headers, CreateLogicForm::default(), None).await) } pub(crate) async fn create_logic( @@ -27,7 +31,7 @@ pub(crate) async fn create_logic( ) -> Response { let submitted = form.clone(); if let Err(error) = load_page(state.clone(), &headers, submitted.clone(), None).await { - return render_loaded(Err(error)); + return render_loaded(&headers, Err(error)); } if headers .get("sec-fetch-site") @@ -35,10 +39,13 @@ pub(crate) async fn create_logic( { return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response(); } - let request = match form.into_request() { + let request = match form.into_request(Locale::from_headers(&headers)) { Ok(request) => request, Err(message) => { - return render_loaded(load_page(state, &headers, submitted, Some(message)).await); + return render_loaded( + &headers, + load_page(state, &headers, submitted, Some(message)).await, + ); } }; let mut scripts = state.scripts; @@ -48,30 +55,43 @@ pub(crate) async fn create_logic( }; match scripts.post_table_script(request).await { Ok(response) => Html(ui::render_success( + Locale::from_headers(&headers), response.get_ref().id, &response.get_ref().warnings, )) .into_response(), Err(error) => ( StatusCode::UNPROCESSABLE_ENTITY, - Html(ui::render_submission_error(error.message())), + Html(ui::render_submission_error( + Locale::from_headers(&headers), + error.message(), + )), ) .into_response(), } } -fn render_loaded(result: Result) -> Response { +fn render_loaded(headers: &HeaderMap, result: Result) -> Response { match result { Ok(page) => Html(ui::render_page(&page)).into_response(), Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(), Err(LoadError::Forbidden) => ( StatusCode::FORBIDDEN, - Html(ui::render_submission_error("Script-management permission is required.")), + Html(ui::render_submission_error( + Locale::from_headers(&headers), + &tr!( + Locale::from_headers(&headers), + "add-logic-err-permission" + ), + )), ) .into_response(), Err(LoadError::Backend(message)) => ( StatusCode::BAD_GATEWAY, - Html(ui::render_submission_error(&message)), + Html(ui::render_submission_error( + Locale::from_headers(&headers), + &message, + )), ) .into_response(), } diff --git a/web/src/pages/add_logic/state.rs b/web/src/pages/add_logic/state.rs index bb20c39d..cb00d5c1 100644 --- a/web/src/pages/add_logic/state.rs +++ b/web/src/pages/add_logic/state.rs @@ -1,4 +1,5 @@ use crate::definitions::table_script::PostTableScriptRequest; +use crate::{i18n::Locale, tr}; #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct CreateLogicForm { @@ -28,20 +29,20 @@ pub(crate) struct TableOption { } impl CreateLogicForm { - pub(crate) fn into_request(self) -> Result { + pub(crate) fn into_request(self, locale: Locale) -> Result { if self.table_definition_id <= 0 { - return Err("Select a target table.".to_string()); + return Err(tr!(locale, "add-logic-err-select-table")); } let target_column = self.target_column.trim().to_string(); if target_column.is_empty() { - return Err("Enter the target column.".to_string()); + return Err(tr!(locale, "add-logic-err-target-column")); } let script = self.script.trim().to_string(); if script.is_empty() { - return Err("Enter a Steel expression.".to_string()); + return Err(tr!(locale, "add-logic-err-script")); } if !script.starts_with('(') { - return Err("The Steel expression must start with `(`.".to_string()); + return Err(tr!(locale, "add-logic-err-script-paren")); } Ok(PostTableScriptRequest { table_definition_id: self.table_definition_id, diff --git a/web/src/pages/add_logic/ui.rs b/web/src/pages/add_logic/ui.rs index 90201fc2..aed97698 100644 --- a/web/src/pages/add_logic/ui.rs +++ b/web/src/pages/add_logic/ui.rs @@ -1,5 +1,6 @@ use askama::Template; +use crate::{i18n::Locale, tr}; use crate::ui::{Alert, Nav, render}; use super::state::AddLogicPageState; @@ -20,15 +21,28 @@ pub(crate) fn render_page(page: &AddLogicPageState) -> String { } /// POST /admin/logic — the #submission-status swaps. -pub(crate) fn render_submission_error(message: &str) -> String { - render(&Alert::error("Could not save the logic", message)) +pub(crate) fn render_submission_error(locale: Locale, message: &str) -> String { + render(&Alert::error( + locale, + &tr!(locale, "add-logic-error-title"), + message, + )) } -pub(crate) fn render_success(id: i64, warnings: &str) -> String { +pub(crate) fn render_success(locale: Locale, id: i64, warnings: &str) -> String { let message = if warnings.trim().is_empty() { - format!("Script #{id} was saved.") + tr!(locale, "add-logic-saved", "id" => id.to_string()) } else { - format!("Script #{id} was saved. Warnings: {warnings}") + tr!( + locale, + "add-logic-saved-warnings", + "id" => id.to_string(), + "warnings" => warnings.to_string(), + ) }; - render(&Alert::success("Logic saved", &message)) + render(&Alert::success( + locale, + &tr!(locale, "add-logic-success-title"), + &message, + )) } diff --git a/web/src/pages/add_table/draft.rs b/web/src/pages/add_table/draft.rs index 5f9bd41c..7a58d142 100644 --- a/web/src/pages/add_table/draft.rs +++ b/web/src/pages/add_table/draft.rs @@ -13,6 +13,7 @@ use crate::{ definitions::table_definition::{GeneratedColumnAlias, PostTableDefinitionRequest}, + {i18n::Locale, tr}, schema::{ ColumnCatalog, ColumnDraft, proto_columns, validate_identifier, validate_table_name, }, @@ -115,11 +116,19 @@ impl TableDraft { // ---- mutations ------------------------------------------------------- /// Removes one column, and drops it from the display columns with it. - pub(crate) fn remove_column(&mut self, index: usize) -> Result { - let removed = self.columns.remove(index)?; + pub(crate) fn remove_column( + &mut self, + locale: Locale, + index: usize, + ) -> Result { + let removed = self.columns.remove(locale, index)?; self.row_display_columns .retain(|display| display != &removed.name); - Ok(format!("Column `{}` removed.", removed.name)) + Ok(tr!( + locale, + "schema-status-column-removed", + "name" => removed.name, + )) } /// Moves one column one place up or down the list. @@ -127,8 +136,13 @@ impl TableDraft { /// The display columns keep their own order, which is the order they were /// chosen in rather than the order the columns are declared in, so nothing /// here touches them. - pub(crate) fn move_column(&mut self, index: usize, offset: isize) -> Option { - self.columns.move_column(index, offset) + pub(crate) fn move_column( + &mut self, + locale: Locale, + index: usize, + offset: isize, + ) -> Option { + self.columns.move_column(locale, index, offset) } /// Adds or removes one display-column candidate. @@ -331,7 +345,7 @@ impl TableDraft { /// Every alias has to be a legal column name, and has to be free: the table /// is about to hold the declared columns, the generated ones and the system /// ones, and two columns cannot share a name. - pub(crate) fn validate_generated_aliases(&self) -> Result<(), String> { + pub(crate) fn validate_generated_aliases(&self, locale: Locale) -> Result<(), String> { let generated = self.aliasable_generated_columns(); let renames = self.aliased_generated_columns(); @@ -350,12 +364,17 @@ impl TableDraft { ); for (source, alias) in &renames { - if let Some(error) = validate_identifier(alias, "Column alias", true) { + if let Some(error) = + validate_identifier(locale, alias, "label-column-alias", true) + { return Err(error); } if taken.iter().filter(|name| *name == alias).count() > 1 { - return Err(format!( - "Alias `{alias}` for generated column `{source}` is already taken by another column." + return Err(tr!( + locale, + "draft-err-alias-taken", + "alias" => alias.clone(), + "source" => source.clone(), )); } } @@ -392,7 +411,7 @@ impl TableDraft { /// The schema as it will exist: system columns, relation columns, then the /// user's own. Mirrors the client's preview pane. - pub(crate) fn preview_rows(&self) -> Vec { + pub(crate) fn preview_rows(&self, locale: &Locale) -> Vec { let mut rows = vec![ PreviewRow { mark: if self.row_display_columns.is_empty() { @@ -402,14 +421,14 @@ impl TableDraft { }, column: "id".to_string(), data_type: "BIGSERIAL".to_string(), - option: "primary key".to_string(), + option: tr!(*locale, "builder-preview-primary-key"), source: "system".to_string(), }, PreviewRow { mark: String::new(), column: "deleted".to_string(), data_type: "BOOLEAN".to_string(), - option: "default false".to_string(), + option: tr!(*locale, "builder-preview-default-false"), source: "system".to_string(), }, // Every managed table carries one, and the preview claims to be the @@ -418,7 +437,7 @@ impl TableDraft { mark: String::new(), column: "row_revision".to_string(), data_type: "BIGINT".to_string(), - option: "not null, default 1".to_string(), + option: tr!(*locale, "builder-preview-not-null-1"), source: "system".to_string(), }, ]; @@ -431,7 +450,7 @@ impl TableDraft { .unwrap_or_else(|| "[ ]".to_string()), column: column.name.clone(), data_type: column.data_type.clone(), - option: column.option_label(), + option: column.option_label(locale), source: "user".to_string(), }); @@ -445,7 +464,15 @@ impl TableDraft { column: self.generated_display_name(&generated.name), data_type: generated.data_type.clone(), option: if generated.inherits_currency { - format!("{}, {}", column.currency, column.money_mode.label()) + let mode = match column.money_mode { + crate::schema::MoneyMode::Exact => "exact", + crate::schema::MoneyMode::Rounded => "half-up", + }; + format!( + "{}, {}", + column.currency, + tr!(*locale, &format!("td-money-{mode}")), + ) } else { String::new() }, @@ -462,9 +489,10 @@ impl TableDraft { mark: String::new(), column: "account_id".to_string(), data_type: "BIGINT".to_string(), - option: format!( - "not null, → accounts, written as {}", - self.generated_display_name(ACCOUNT_API_COLUMN) + option: tr!( + *locale, + "builder-preview-account-option", + "field" => self.generated_display_name(ACCOUNT_API_COLUMN), ), source: "system".to_string(), }); @@ -475,7 +503,7 @@ impl TableDraft { mark: String::new(), column: "created_at".to_string(), data_type: "TIMESTAMPTZ".to_string(), - option: "current time".to_string(), + option: tr!(*locale, "builder-preview-current-time"), source: "system".to_string(), }); rows @@ -484,18 +512,22 @@ impl TableDraft { // ---- validation and submission --------------------------------------- /// Every check the client runs before it will save. - pub(crate) fn validate(&self) -> Result<(), String> { + pub(crate) fn validate(&self, locale: Locale) -> Result<(), String> { let profile_name = self.effective_profile_name(); if !self.global && self.creating_new_profile && profile_name.is_empty() { - return Err("Enter a name for the new profile.".to_string()); + return Err(tr!(locale, "draft-err-profile-name")); } - if !self.global && let Some(error) = validate_identifier(&profile_name, "Profile name", false) { + if !self.global { + if let Some(error) = + validate_identifier(locale, &profile_name, "label-profile-name", false) + { + return Err(error); + } + } + if let Some(error) = validate_accounting_currency(locale, self) { return Err(error); } - if let Some(error) = validate_accounting_currency(self) { - return Err(error); - } - if let Some(error) = validate_table_name(self.table_name.trim()) { + if let Some(error) = validate_table_name(locale, self.table_name.trim()) { return Err(error); } if self.table_name_conflicts() { @@ -503,27 +535,33 @@ impl TableDraft { // be free in all of them — and naming a profile in that message // would name the wrong thing, there being none. return Err(if self.global { - format!( - "A table named `{}` already exists. A shared table's name has to be free in every profile.", - self.table_name + tr!( + locale, + "draft-err-table-conflict-global", + "name" => self.table_name.clone(), ) } else { - format!( - "A table named `{}` already exists in profile `{profile_name}`, or is shared by every profile.", - self.table_name + tr!( + locale, + "draft-err-table-conflict", + "name" => self.table_name.clone(), + "profile" => profile_name.clone(), ) }); } if self.columns.is_empty() { - return Err("Add at least one column before saving.".to_string()); + return Err(tr!(locale, "draft-err-no-columns")); } - self.validate_generated_aliases()?; - self.columns.validate() + self.validate_generated_aliases(locale)?; + self.columns.validate(locale) } - pub(crate) fn into_request(mut self) -> Result { + pub(crate) fn into_request( + mut self, + locale: Locale, + ) -> Result { self.table_name = self.table_name.trim().to_string(); - self.validate()?; + self.validate(locale)?; Ok(PostTableDefinitionRequest { table_name: self.table_name.clone(), @@ -545,13 +583,13 @@ impl TableDraft { } } -pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option { +pub(crate) fn validate_accounting_currency(locale: Locale, draft: &TableDraft) -> Option { if !draft.creating_new_profile || draft.global { return None; } let currency = draft.accounting_currency.to_ascii_uppercase(); if rusty_money::iso::find(¤cy).is_none() { - return Some("Accounting currency must be a three-letter ISO-4217 code".to_string()); + return Some(tr!(locale, "draft-err-accounting-currency")); } None } @@ -591,11 +629,11 @@ mod tests { draft.profile_name_input = "billing".to_string(); draft.accounting_currency = "AAA".to_string(); - assert!(draft.validate().is_err()); + assert!(draft.validate(crate::i18n::Locale::default()).is_err()); draft.accounting_currency = "eur".to_string(); - assert!(draft.validate().is_ok()); - assert_eq!(draft.into_request().unwrap().accounting_currency, "EUR"); + assert!(draft.validate(crate::i18n::Locale::default()).is_ok()); + assert_eq!(draft.into_request(crate::i18n::Locale::default()).unwrap().accounting_currency, "EUR"); } /// Every column an ACCOUNTING row generates may be aliased, including the @@ -669,16 +707,16 @@ mod tests { ); assert_eq!(draft.generated_display_name("debit"), "md"); assert_eq!(draft.generated_display_name("credit"), "credit"); - assert!(draft.validate().is_ok()); + assert!(draft.validate(crate::i18n::Locale::default()).is_ok()); assert!( draft - .preview_rows() + .preview_rows(&crate::i18n::Locale::default()) .iter() .any(|row| row.column == "md" && row.source == "generated") ); // The request that creates the columns is the request that names them. - let request = draft.into_request().unwrap(); + let request = draft.into_request(crate::i18n::Locale::default()).unwrap(); assert_eq!(request.generated_aliases.len(), 1); assert_eq!(request.generated_aliases[0].generated_name, "debit"); assert_eq!(request.generated_aliases[0].alias, "md"); @@ -701,25 +739,25 @@ mod tests { source: "debit".to_string(), alias: "Md".to_string(), }]; - assert!(draft.validate().is_err(), "an alias is a column name"); + assert!(draft.validate(crate::i18n::Locale::default()).is_err(), "an alias is a column name"); draft.generated_aliases[0].alias = "note".to_string(); - assert!(draft.validate().is_err(), "a declared column holds the name"); + assert!(draft.validate(crate::i18n::Locale::default()).is_err(), "a declared column holds the name"); draft.generated_aliases[0].alias = "credit".to_string(); - assert!(draft.validate().is_err(), "another generated column does"); + assert!(draft.validate(crate::i18n::Locale::default()).is_err(), "another generated column does"); draft.generated_aliases[0].alias = "id".to_string(); - assert!(draft.validate().is_err(), "a system column does"); + assert!(draft.validate(crate::i18n::Locale::default()).is_err(), "a system column does"); draft.generated_aliases[0].alias = "md".to_string(); - assert!(draft.validate().is_ok()); + assert!(draft.validate(crate::i18n::Locale::default()).is_ok()); } #[test] fn existing_profile_sends_no_accounting_currency() { let draft = draft_with_column("total", "int"); - let request = draft.into_request().unwrap(); + let request = draft.into_request(crate::i18n::Locale::default()).unwrap(); assert_eq!(request.accounting_currency, ""); assert_eq!(request.profile_name, "billing"); @@ -730,13 +768,13 @@ mod tests { let mut draft = draft_with_column("total", "int"); draft.existing_profile_tables = vec!["invoice".to_string()]; - let error = draft.validate().unwrap_err(); + let error = draft.validate(crate::i18n::Locale::default()).unwrap_err(); assert!(error.contains("profile `billing`"), "{error}"); // A shared table has no profile to name, and its name has to be free // everywhere rather than in one place. draft.global = true; - let error = draft.validate().unwrap_err(); + let error = draft.validate(crate::i18n::Locale::default()).unwrap_err(); assert!(!error.contains("profile ``"), "{error}"); assert!(error.contains("every profile"), "{error}"); } @@ -746,15 +784,15 @@ mod tests { let mut draft = draft_with_column("total", "int"); draft.profile_name = "pg_catalog".to_string(); - assert!(draft.validate().is_err()); + assert!(draft.validate(crate::i18n::Locale::default()).is_err()); } #[test] fn a_table_with_no_columns_is_refused() { let mut draft = draft_with_column("total", "int"); - draft.remove_column(0).unwrap(); + draft.remove_column(crate::i18n::Locale::default(), 0).unwrap(); - assert!(draft.validate().is_err()); + assert!(draft.validate(crate::i18n::Locale::default()).is_err()); } #[test] @@ -840,7 +878,7 @@ mod tests { draft.toggle_row_display_candidate(1); assert_eq!(draft.row_display_columns, vec!["number"]); - draft.remove_column(0).unwrap(); + draft.remove_column(crate::i18n::Locale::default(), 0).unwrap(); assert!(draft.row_display_columns.is_empty()); } @@ -848,7 +886,7 @@ mod tests { fn the_preview_shows_system_and_user_columns() { let draft = draft_with_column("number", "text"); - let rows = draft.preview_rows(); + let rows = draft.preview_rows(&crate::i18n::Locale::default()); let columns = rows .iter() .map(|row| row.column.as_str()) @@ -880,7 +918,7 @@ mod tests { draft.toggle_row_display_candidate(1); // number draft.toggle_row_display_candidate(2); // issued_on - draft.move_column(0, 1).unwrap(); + draft.move_column(crate::i18n::Locale::default(), 0, 1).unwrap(); assert_eq!( draft @@ -909,7 +947,7 @@ mod tests { currency: "CZK".to_string(), }); - let rows = draft.preview_rows(); + let rows = draft.preview_rows(&crate::i18n::Locale::default()); let columns = rows .iter() .map(|row| row.column.as_str()) @@ -954,7 +992,7 @@ mod tests { let mut draft = draft_with_column("number", "text"); draft.columns.toggle_indexed(0); - assert_eq!(draft.into_request().unwrap().indexes, vec!["number"]); + assert_eq!(draft.into_request(crate::i18n::Locale::default()).unwrap().indexes, vec!["number"]); } /// The request the builder sends is one the server will take: a link @@ -976,12 +1014,15 @@ mod tests { }); // Refused rather than quietly repaired, because the server refuses it. - let error = draft.clone().into_request().unwrap_err(); + let error = draft + .clone() + .into_request(crate::i18n::Locale::default()) + .unwrap_err(); assert!(error.contains("indexed automatically"), "{error}"); draft.columns.added[1].indexed = false; draft.columns.toggle_indexed(0); - let request = draft.into_request().unwrap(); + let request = draft.into_request(crate::i18n::Locale::default()).unwrap(); assert_eq!(request.indexes, vec!["number"]); assert!(request.columns[0].required); @@ -995,16 +1036,16 @@ mod tests { let mut draft = draft_with_column("total", "int"); draft.table_name = "t".repeat(39); - let error = draft.validate().unwrap_err(); + let error = draft.validate(crate::i18n::Locale::default()).unwrap_err(); assert!(error.contains("38 characters"), "{error}"); draft.table_name = "accounts".to_string(); - assert!(draft.validate().is_err()); + assert!(draft.validate(crate::i18n::Locale::default()).is_err()); draft.table_name = "general_ledger".to_string(); - assert!(draft.validate().is_err()); + assert!(draft.validate(crate::i18n::Locale::default()).is_err()); draft.table_name = "invoice".to_string(); - assert!(draft.validate().is_ok()); + assert!(draft.validate(crate::i18n::Locale::default()).is_ok()); } /// A shared table has no books of its own, so the two definition rows that @@ -1015,7 +1056,7 @@ mod tests { draft.global = true; draft.columns.global = true; - assert!(draft.validate().is_err()); + assert!(draft.validate(crate::i18n::Locale::default()).is_err()); draft.columns.added.clear(); draft.columns.added.push(ColumnDefinition { @@ -1027,7 +1068,7 @@ mod tests { money_mode: MoneyMode::Exact, currency: String::new(), }); - assert!(draft.validate().is_err()); + assert!(draft.validate(crate::i18n::Locale::default()).is_err()); } /// The preview is the schema as it will exist, so it carries every system @@ -1038,7 +1079,7 @@ mod tests { let draft = draft_with_column("number", "text"); assert!( draft - .preview_rows() + .preview_rows(&crate::i18n::Locale::default()) .iter() .any(|row| row.column == "row_revision" && row.source == "system") ); diff --git a/web/src/pages/add_table/logic.rs b/web/src/pages/add_table/logic.rs index 0b17a9b7..c1ea4915 100644 --- a/web/src/pages/add_table/logic.rs +++ b/web/src/pages/add_table/logic.rs @@ -13,6 +13,7 @@ use axum_extra::extract::Form; use crate::{ AppState, + {i18n::Locale, tr}, services::{authenticated_request, reject_cross_site}, }; @@ -45,7 +46,7 @@ pub(crate) async fn new_table_page( match load_page(state, &headers, draft, None, None).await { Ok(page) => Html(ui::render_page(&page)).into_response(), - Err(error) => load_error_response(error), + Err(error) => load_error_response(&headers, error), } } @@ -61,7 +62,7 @@ pub(crate) async fn update_builder( let mut page = match load_page(state, &headers, form.to_draft(), None, None).await { Ok(page) => page, - Err(error) => return load_error_response(error), + Err(error) => return load_error_response(&headers, error), }; apply_action(&mut page, &form); @@ -80,12 +81,12 @@ pub(crate) async fn create_table( let mut page = match load_page(state.clone(), &headers, form.to_draft(), None, None).await { Ok(page) => page, - Err(error) => return load_error_response(error), + Err(error) => return load_error_response(&headers, error), }; // The draft is validated here with exactly the checks the client runs // before it will save; the server re-validates authoritatively. - let request = match page.draft.clone().into_request() { + let request = match page.draft.clone().into_request(Locale::from_headers(&headers)) { Ok(request) => request, Err(message) => { page.error = Some(message); @@ -127,7 +128,10 @@ pub(crate) async fn create_table( } Ok(response) => { page.error = Some(if response.get_ref().sql.is_empty() { - "The backend did not create the table.".to_string() + tr!( + Locale::from_headers(&headers), + "builder-err-backend-no-create" + ) } else { response.get_ref().sql.clone() }); @@ -146,30 +150,35 @@ pub(crate) async fn create_table( /// changes which fields apply, which is the client's field-visibility rule. fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) { let index = form.index.unwrap_or(0); + let locale = page.nav.locale; match form.action.as_str() { - "add-column" => match page.draft.columns.add_from_inputs() { + "add-column" => match page.draft.columns.add_from_inputs(locale) { Ok(status) => page.status = Some(status), Err(message) => page.error = Some(message), }, - "remove-column" => match page.draft.remove_column(index) { + "remove-column" => match page.draft.remove_column(locale, index) { Ok(status) => page.status = Some(status), Err(message) => page.error = Some(message), }, // The order columns are declared in is the order the table gets them // in, so moving one is a change to the draft like any other. - "move-column-up" => page.status = page.draft.move_column(index, -1), - "move-column-down" => page.status = page.draft.move_column(index, 1), + "move-column-up" => page.status = page.draft.move_column(locale, index, -1), + "move-column-down" => page.status = page.draft.move_column(locale, index, 1), // The alias fields carry no `change` trigger of their own — a text // input fires `change` on blur, which would swap the builder out from // under the click that is still in flight. This button is what applies // what was typed, so the column list and the preview say what the // table will really be called. - "apply-generated-names" => match page.draft.validate_generated_aliases() { + "apply-generated-names" => match page.draft.validate_generated_aliases(locale) { Ok(()) => { page.status = Some(match page.draft.aliased_generated_columns().len() { - 0 => "The generated columns keep their own names.".to_string(), - 1 => "1 generated column renamed.".to_string(), - count => format!("{count} generated columns renamed."), + 0 => tr!(locale, "builder-status-generated-keep"), + 1 => tr!(locale, "builder-status-generated-one"), + count => tr!( + locale, + "builder-status-generated-many", + "count" => count as i64, + ), }) } Err(message) => page.error = Some(message), @@ -182,7 +191,7 @@ fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) { // one that does: the picker stops offering the columns that post to a // profile's books, and one added before the switch is still there. "refresh" => { - if let Err(message) = page.draft.columns.validate() { + if let Err(message) = page.draft.columns.validate(locale) { page.error = Some(message); } } @@ -190,19 +199,26 @@ fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) { } } -fn load_error_response(error: LoadError) -> Response { +fn load_error_response(headers: &HeaderMap, error: LoadError) -> Response { match error { LoadError::Unauthenticated => Redirect::to("/login").into_response(), LoadError::Forbidden => ( StatusCode::FORBIDDEN, Html(ui::render_submission_error( - "Table-management permission is required.", + Locale::from_headers(headers), + &tr!( + Locale::from_headers(headers), + "builder-err-permission" + ), )), ) .into_response(), LoadError::Backend(message) => ( StatusCode::BAD_GATEWAY, - Html(ui::render_submission_error(&message)), + Html(ui::render_submission_error( + Locale::from_headers(headers), + &message, + )), ) .into_response(), } diff --git a/web/src/pages/add_table/ui.rs b/web/src/pages/add_table/ui.rs index f9ac1846..cce9c359 100644 --- a/web/src/pages/add_table/ui.rs +++ b/web/src/pages/add_table/ui.rs @@ -1,6 +1,7 @@ use askama::Template; use crate::{ + {i18n::Locale, tr}, schema::CURRENCY_CODES, ui::{Alert, Nav, render}, }; @@ -24,6 +25,7 @@ struct AddTablePage<'a> { #[derive(Template)] #[template(path = "pages/add_table/builder.html")] struct BuilderFragment<'a> { + nav: Nav, page: &'a AddTablePageState, column_types: Vec, temporal_types: Vec, @@ -45,6 +47,7 @@ pub(crate) fn render_page(page: &AddTablePageState) -> String { pub(crate) fn render_builder(page: &AddTablePageState) -> String { render(&BuilderFragment { + nav: page.nav.clone(), page, column_types: page.draft.columns.offered_types(), temporal_types: page.draft.columns.temporal_types(), @@ -55,8 +58,12 @@ pub(crate) fn render_builder(page: &AddTablePageState) -> String { /// Used when the page itself cannot be loaded (auth or backend failure). /// There is no draft left to render, so this replaces the builder — the dialog /// is what tells the user why the form just emptied. -pub(crate) fn render_submission_error(message: &str) -> String { - render(&Alert::error("Could not create the table", message)) +pub(crate) fn render_submission_error(locale: Locale, message: &str) -> String { + render(&Alert::error( + locale, + &tr!(locale, "builder-err-title"), + message, + )) } #[cfg(test)] diff --git a/web/src/pages/add_validation/logic.rs b/web/src/pages/add_validation/logic.rs index 446364ae..dfd21e04 100644 --- a/web/src/pages/add_validation/logic.rs +++ b/web/src/pages/add_validation/logic.rs @@ -5,7 +5,11 @@ use axum::{ response::{Html, IntoResponse, Redirect, Response}, }; -use crate::{AppState, services::authenticated_request}; +use crate::{ + AppState, + {i18n::Locale, tr}, + services::authenticated_request, +}; use super::{ loader::{LoadError, load_page, load_set_page}, @@ -14,15 +18,15 @@ use super::{ }; pub(crate) async fn new_field_validation_page(State(state): State, headers: HeaderMap) -> Response { - render_loaded(load_page(state, &headers, ValidationForm::default(), false, None).await) + render_loaded(&headers, load_page(state, &headers, ValidationForm::default(), false, None).await) } pub(crate) async fn new_validation_rule_page(State(state): State, headers: HeaderMap) -> Response { - render_loaded(load_page(state, &headers, ValidationForm::default(), true, None).await) + render_loaded(&headers, load_page(state, &headers, ValidationForm::default(), true, None).await) } pub(crate) async fn new_validation_set_page(State(state): State, headers: HeaderMap) -> Response { - render_set_loaded(load_set_page(state, &headers, ValidationSetForm::default(), None).await) + render_set_loaded(&headers, load_set_page(state, &headers, ValidationSetForm::default(), None).await) } pub(crate) async fn save_field_validation( @@ -30,15 +34,20 @@ pub(crate) async fn save_field_validation( ) -> Response { let submitted = form.clone(); if let Err(error) = load_page(state.clone(), &headers, submitted.clone(), false, None).await { - return render_loaded(Err(error)); + return render_loaded(&headers, Err(error)); } if cross_site(&headers) { return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response(); } if !form.validation_set_name.trim().is_empty() { - let request = match form.apply_set_request() { + let request = match form.apply_set_request(Locale::from_headers(&headers)) { Ok(request) => request, - Err(message) => return render_loaded(load_page(state, &headers, submitted, false, Some(message)).await), + Err(message) => { + return render_loaded( + &headers, + load_page(state, &headers, submitted, false, Some(message)).await, + ) + } }; let mut validations = state.validations; let request = match authenticated_request(&headers, request) { @@ -46,14 +55,37 @@ pub(crate) async fn save_field_validation( Err(_) => return Redirect::to("/login").into_response(), }; return match validations.apply_validation_set(request).await { - Ok(response) if response.get_ref().success => Html(ui::render_success(&response.get_ref().message)).into_response(), - Ok(response) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(&response.get_ref().message))).into_response(), - Err(error) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(error.message()))).into_response(), + Ok(response) if response.get_ref().success => Html(ui::render_success( + Locale::from_headers(&headers), + &response.get_ref().message, + )) + .into_response(), + Ok(response) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Html(ui::render_error( + Locale::from_headers(&headers), + &response.get_ref().message, + )), + ) + .into_response(), + Err(error) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Html(ui::render_error( + Locale::from_headers(&headers), + error.message(), + )), + ) + .into_response(), }; } - let request = match form.field_request() { + let request = match form.field_request(Locale::from_headers(&headers)) { Ok(request) => request, - Err(message) => return render_loaded(load_page(state, &headers, submitted, false, Some(message)).await), + Err(message) => { + return render_loaded( + &headers, + load_page(state, &headers, submitted, false, Some(message)).await, + ) + } }; let mut validations = state.validations; let request = match authenticated_request(&headers, request) { @@ -61,9 +93,27 @@ pub(crate) async fn save_field_validation( Err(_) => return Redirect::to("/login").into_response(), }; match validations.update_field_validation(request).await { - Ok(response) if response.get_ref().success => Html(ui::render_success(&response.get_ref().message)).into_response(), - Ok(response) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(&response.get_ref().message))).into_response(), - Err(error) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(error.message()))).into_response(), + Ok(response) if response.get_ref().success => Html(ui::render_success( + Locale::from_headers(&headers), + &response.get_ref().message, + )) + .into_response(), + Ok(response) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Html(ui::render_error( + Locale::from_headers(&headers), + &response.get_ref().message, + )), + ) + .into_response(), + Err(error) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Html(ui::render_error( + Locale::from_headers(&headers), + error.message(), + )), + ) + .into_response(), } } @@ -72,14 +122,19 @@ pub(crate) async fn save_validation_rule( ) -> Response { let submitted = form.clone(); if let Err(error) = load_page(state.clone(), &headers, submitted.clone(), true, None).await { - return render_loaded(Err(error)); + return render_loaded(&headers, Err(error)); } if cross_site(&headers) { return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response(); } - let request = match form.rule_request() { + let request = match form.rule_request(Locale::from_headers(&headers)) { Ok(request) => request, - Err(message) => return render_loaded(load_page(state, &headers, submitted, true, Some(message)).await), + Err(message) => { + return render_loaded( + &headers, + load_page(state, &headers, submitted, true, Some(message)).await, + ) + } }; let mut validations = state.validations; let request = match authenticated_request(&headers, request) { @@ -87,9 +142,27 @@ pub(crate) async fn save_validation_rule( Err(_) => return Redirect::to("/login").into_response(), }; match validations.upsert_validation_rule(request).await { - Ok(response) if response.get_ref().success => Html(ui::render_success(&response.get_ref().message)).into_response(), - Ok(response) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(&response.get_ref().message))).into_response(), - Err(error) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(error.message()))).into_response(), + Ok(response) if response.get_ref().success => Html(ui::render_success( + Locale::from_headers(&headers), + &response.get_ref().message, + )) + .into_response(), + Ok(response) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Html(ui::render_error( + Locale::from_headers(&headers), + &response.get_ref().message, + )), + ) + .into_response(), + Err(error) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Html(ui::render_error( + Locale::from_headers(&headers), + error.message(), + )), + ) + .into_response(), } } @@ -98,14 +171,19 @@ pub(crate) async fn save_validation_set( ) -> Response { let submitted = form.clone(); if let Err(error) = load_set_page(state.clone(), &headers, submitted.clone(), None).await { - return render_set_loaded(Err(error)); + return render_set_loaded(&headers, Err(error)); } if cross_site(&headers) { return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response(); } - let request = match form.request() { + let request = match form.request(Locale::from_headers(&headers)) { Ok(request) => request, - Err(message) => return render_set_loaded(load_set_page(state, &headers, submitted, Some(message)).await), + Err(message) => { + return render_set_loaded( + &headers, + load_set_page(state, &headers, submitted, Some(message)).await, + ) + } }; let mut validations = state.validations; let request = match authenticated_request(&headers, request) { @@ -113,13 +191,32 @@ pub(crate) async fn save_validation_set( Err(_) => return Redirect::to("/login").into_response(), }; match validations.upsert_validation_set(request).await { - Ok(response) if response.get_ref().success => Html(ui::render_success(&response.get_ref().message)).into_response(), - Ok(response) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(&response.get_ref().message))).into_response(), - Err(error) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(error.message()))).into_response(), + Ok(response) if response.get_ref().success => Html(ui::render_success( + Locale::from_headers(&headers), + &response.get_ref().message, + )) + .into_response(), + Ok(response) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Html(ui::render_error( + Locale::from_headers(&headers), + &response.get_ref().message, + )), + ) + .into_response(), + Err(error) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Html(ui::render_error( + Locale::from_headers(&headers), + error.message(), + )), + ) + .into_response(), } } fn render_set_loaded( + headers: &HeaderMap, result: Result<(crate::ui::Nav, ValidationSetForm, Option), LoadError>, ) -> Response { match result { @@ -127,17 +224,48 @@ fn render_set_loaded( Html(ui::render_set_page(nav, &form, error.as_deref())).into_response() } Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(), - Err(LoadError::Forbidden) => (StatusCode::FORBIDDEN, Html(ui::render_error("Validation-management permission is required."))).into_response(), - Err(LoadError::Backend(message)) => (StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response(), + Err(LoadError::Forbidden) => ( + StatusCode::FORBIDDEN, + Html(ui::render_error( + Locale::from_headers(headers), + &tr!( + Locale::from_headers(headers), + "validation-err-permission" + ), + )), + ) + .into_response(), + Err(LoadError::Backend(message)) => ( + StatusCode::BAD_GATEWAY, + Html(ui::render_error(Locale::from_headers(headers), &message)), + ) + .into_response(), } } -fn render_loaded(result: Result) -> Response { +fn render_loaded( + headers: &HeaderMap, + result: Result, +) -> Response { match result { Ok(page) => Html(ui::render_page(&page)).into_response(), Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(), - Err(LoadError::Forbidden) => (StatusCode::FORBIDDEN, Html(ui::render_error("Validation-management permission is required."))).into_response(), - Err(LoadError::Backend(message)) => (StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response(), + Err(LoadError::Forbidden) => ( + StatusCode::FORBIDDEN, + Html(ui::render_error( + Locale::from_headers(headers), + &tr!( + Locale::from_headers(headers), + "validation-err-permission" + ), + )), + ) + .into_response(), + Err(LoadError::Backend(message)) => ( + StatusCode::BAD_GATEWAY, + Html(ui::render_error(Locale::from_headers(headers), &message)), + ) + .into_response(), } } diff --git a/web/src/pages/add_validation/state.rs b/web/src/pages/add_validation/state.rs index aea97327..3cd3a17c 100644 --- a/web/src/pages/add_validation/state.rs +++ b/web/src/pages/add_validation/state.rs @@ -4,6 +4,7 @@ use crate::definitions::table_validation::{ UpsertValidationRuleRequest, UpsertValidationSetRequest, ValidationRuleDefinition, ValidationSetDefinition, ValidationSetRuleItem, validation_set_rule_item, }; +use crate::{i18n::Locale, tr}; #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct ValidationForm { @@ -71,11 +72,11 @@ pub(crate) struct ValidationSetForm { } impl ValidationSetForm { - pub(crate) fn request(&self) -> Result { - let name = required(&self.name, "set name")?; + pub(crate) fn request(&self, locale: Locale) -> Result { + let name = required(locale, &self.name, "label-set-name")?; let rules = comma_separated(&self.global_rules); if rules.is_empty() { - return Err("Enter at least one reusable rule name.".to_string()); + return Err(tr!(locale, "validation-err-at-least-one-rule")); } let rule_items = rules.into_iter().enumerate().map(|(position, rule_name)| ValidationSetRuleItem { position: position as i32, @@ -96,47 +97,56 @@ impl ValidationSetForm { } impl ValidationForm { - pub(crate) fn field_request(&self) -> Result { - let profile_name = required(&self.profile_name, "profile")?; - let table_name = required(&self.table_name, "table")?; - let data_key = required(&self.data_key, "column")?; + pub(crate) fn field_request( + &self, + locale: Locale, + ) -> Result { + let profile_name = required(locale, &self.profile_name, "label-profile")?; + let table_name = required(locale, &self.table_name, "label-table")?; + let data_key = required(locale, &self.data_key, "label-column")?; Ok(UpdateFieldValidationRequest { profile_name, table_name, data_key: data_key.clone(), - validation: Some(self.validation(data_key)?), + validation: Some(self.validation(locale, data_key)?), }) } - pub(crate) fn rule_request(&self) -> Result { - let name = required(&self.rule_name, "rule name")?; + pub(crate) fn rule_request( + &self, + locale: Locale, + ) -> Result { + let name = required(locale, &self.rule_name, "label-rule-name")?; Ok(UpsertValidationRuleRequest { profile_name: String::new(), rule: Some(ValidationRuleDefinition { id: None, name, description: nonempty(&self.description), - validation: Some(self.validation(String::new())?), + validation: Some(self.validation(locale, String::new())?), }), }) } - pub(crate) fn apply_set_request(&self) -> Result { + pub(crate) fn apply_set_request( + &self, + locale: Locale, + ) -> Result { Ok(ApplyValidationSetRequest { - profile_name: required(&self.profile_name, "profile")?, - table_name: required(&self.table_name, "table")?, - data_key: required(&self.data_key, "column")?, - set_name: required(&self.validation_set_name, "validation set name")?, + profile_name: required(locale, &self.profile_name, "label-profile")?, + table_name: required(locale, &self.table_name, "label-table")?, + data_key: required(locale, &self.data_key, "label-column")?, + set_name: required(locale, &self.validation_set_name, "label-validation-set-name")?, }) } - fn validation(&self, data_key: String) -> Result { - let minimum = parse_u32(&self.minimum, "Minimum")?.unwrap_or(0); - let maximum = parse_u32(&self.maximum, "Maximum")?.unwrap_or(0); + fn validation(&self, locale: Locale, data_key: String) -> Result { + let minimum = parse_u32(locale, &self.minimum, "label-minimum")?.unwrap_or(0); + let maximum = parse_u32(locale, &self.maximum, "label-maximum")?.unwrap_or(0); if maximum > 0 && minimum > maximum { - return Err("Minimum cannot be greater than maximum.".to_string()); + return Err(tr!(locale, "validation-err-min-max")); } - let warn_at = parse_u32(&self.warn_at, "Warning threshold")?; + let warn_at = parse_u32(locale, &self.warn_at, "label-warning-threshold")?; let count_mode = match self.count_mode.as_str() { "bytes" => 2, "display-width" => 3, @@ -165,7 +175,7 @@ impl ValidationForm { template_char: nonempty(&self.mask_template_char), storage_mode: None, }); - let pattern = parse_pattern_rules(&self.pattern_rules, &self.pattern_description)?; + let pattern = parse_pattern_rules(locale, &self.pattern_rules, &self.pattern_description)?; Ok(FieldValidation { data_key, limits, @@ -179,21 +189,31 @@ impl ValidationForm { } } -fn required(value: &str, label: &str) -> Result { +fn required(locale: Locale, value: &str, label_key: &str) -> Result { let value = value.trim(); if value.is_empty() { - Err(format!("Enter a {label}.")) + Err(tr!( + locale, + "validation-err-enter", + "label" => tr!(locale, label_key), + )) } else { Ok(value.to_string()) } } -fn parse_u32(value: &str, label: &str) -> Result, String> { +fn parse_u32(locale: Locale, value: &str, label_key: &str) -> Result, String> { let value = value.trim(); if value.is_empty() { Ok(None) } else { - value.parse().map(Some).map_err(|_| format!("{label} must be a non-negative integer.")) + value.parse().map(Some).map_err(|_| { + tr!( + locale, + "validation-err-non-negative", + "label" => tr!(locale, label_key), + ) + }) } } @@ -206,44 +226,71 @@ fn nonempty(value: &str) -> Option { (!value.is_empty()).then(|| value.to_string()) } -fn parse_pattern_rules(value: &str, description: &str) -> Result, String> { +fn parse_pattern_rules( + locale: Locale, + value: &str, + description: &str, +) -> Result, String> { let mut rules = Vec::new(); for (index, line) in value.lines().enumerate() { let line = line.trim(); if line.is_empty() { continue; } let Some((position, constraint)) = line.split_once('|') else { - return Err(format!("Pattern line {} must use `position | constraint`.", index + 1)); + return Err(tr!( + locale, + "validation-err-pattern-line", + "line" => (index + 1) as i64, + )); }; rules.push(PatternRule { - position: Some(parse_position(position.trim())?), - constraint: Some(parse_constraint(constraint.trim())?), + position: Some(parse_position(locale, position.trim())?), + constraint: Some(parse_constraint(locale, constraint.trim())?), }); } Ok((!rules.is_empty()).then_some(PatternRules { rules, description: nonempty(description) })) } -fn parse_position(value: &str) -> Result { +fn parse_position(locale: Locale, value: &str) -> Result { if let Some(start) = value.strip_suffix('+') { - return Ok(PatternPosition { kind: 3, start: parse_position_number(start)?, ..Default::default() }); + return Ok(PatternPosition { + kind: 3, + start: parse_position_number(locale, start)?, + ..Default::default() + }); } if value.contains(',') { - let positions = value.split(',').map(parse_position_number).collect::, _>>()?; + let positions = value + .split(',') + .map(|position| parse_position_number(locale, position)) + .collect::, _>>()?; return Ok(PatternPosition { kind: 4, positions, ..Default::default() }); } if let Some((start, end)) = value.split_once('-') { - let start = parse_position_number(start)?; - let end = parse_position_number(end)?; - if start > end { return Err("Pattern range start cannot exceed its end.".to_string()); } + let start = parse_position_number(locale, start)?; + let end = parse_position_number(locale, end)?; + if start > end { + return Err(tr!(locale, "validation-err-range-start")); + } return Ok(PatternPosition { kind: 2, start, end, ..Default::default() }); } - Ok(PatternPosition { kind: 1, single: parse_position_number(value)?, ..Default::default() }) + Ok(PatternPosition { + kind: 1, + single: parse_position_number(locale, value)?, + ..Default::default() + }) } -fn parse_position_number(value: &str) -> Result { - value.trim().parse().map_err(|_| format!("Invalid pattern position '{}'.", value.trim())) +fn parse_position_number(locale: Locale, value: &str) -> Result { + value.trim().parse().map_err(|_| { + tr!( + locale, + "validation-err-bad-position", + "value" => value.trim().to_string(), + ) + }) } -fn parse_constraint(value: &str) -> Result { +fn parse_constraint(locale: Locale, value: &str) -> Result { let normalized = value.to_ascii_lowercase(); match normalized.as_str() { "alphabetic" => Ok(CharacterConstraint { kind: 1, ..Default::default() }), @@ -256,7 +303,11 @@ fn parse_constraint(value: &str) -> Result { ..Default::default() }), _ if normalized.starts_with("regex=") => Ok(CharacterConstraint { kind: 6, regex: Some(value[6..].to_string()), ..Default::default() }), - _ => Err(format!("Unknown character constraint '{value}'.")), + _ => Err(tr!( + locale, + "validation-err-bad-constraint", + "value" => value.to_string(), + )), } } @@ -266,7 +317,13 @@ mod pattern_tests { #[test] fn parses_position_and_constraint_rules() { - let rules = parse_pattern_rules("0-3 | alphabetic\n4,5 | one-of=-,+\n6+ | regex=[0-9]", "code").unwrap().unwrap(); + let rules = parse_pattern_rules( + Locale::default(), + "0-3 | alphabetic\n4,5 | one-of=-,+\n6+ | regex=[0-9]", + "code", + ) + .unwrap() + .unwrap(); assert_eq!(rules.rules.len(), 3); assert_eq!(rules.rules[0].position.as_ref().unwrap().kind, 2); assert_eq!(rules.rules[1].constraint.as_ref().unwrap().kind, 5); diff --git a/web/src/pages/add_validation/ui.rs b/web/src/pages/add_validation/ui.rs index aa41a06b..689c057a 100644 --- a/web/src/pages/add_validation/ui.rs +++ b/web/src/pages/add_validation/ui.rs @@ -1,5 +1,6 @@ use askama::Template; +use crate::{i18n::Locale, tr}; use crate::ui::{Alert, Nav, render}; use super::state::{ValidationPageState, ValidationSetForm}; @@ -43,10 +44,18 @@ pub(crate) fn render_set_page(nav: Nav, form: &ValidationSetForm, error: Option< } /// The #submission-status swaps for all three validation POST endpoints. -pub(crate) fn render_error(message: &str) -> String { - render(&Alert::error("Could not save validation", message)) +pub(crate) fn render_error(locale: Locale, message: &str) -> String { + render(&Alert::error( + locale, + &tr!(locale, "validation-error-title"), + message, + )) } -pub(crate) fn render_success(message: &str) -> String { - render(&Alert::success("Validation saved", message)) +pub(crate) fn render_success(locale: Locale, message: &str) -> String { + render(&Alert::success( + locale, + &tr!(locale, "validation-success-title"), + message, + )) } diff --git a/web/src/pages/admin/admin/logic.rs b/web/src/pages/admin/admin/logic.rs index e21f7f7d..ce815f98 100644 --- a/web/src/pages/admin/admin/logic.rs +++ b/web/src/pages/admin/admin/logic.rs @@ -19,7 +19,7 @@ pub(crate) async fn admin_page( ) -> Response { match load_admin_page(state, &headers, selection).await { Ok(page) => Html(ui::render_page(&page)).into_response(), - Err(error) => error_response(error), + Err(error) => error_response(&headers, error), } } @@ -30,7 +30,7 @@ pub(crate) async fn admin_workspace( ) -> Response { match load_admin_page(state, &headers, selection).await { Ok(page) => Html(ui::render_workspace(&page)).into_response(), - Err(error) => error_response(error), + Err(error) => error_response(&headers, error), } } @@ -48,24 +48,27 @@ pub(crate) async fn logout() -> Response { response } -fn error_response(error: LoadError) -> Response { +fn error_response(headers: &HeaderMap, error: LoadError) -> Response { + use crate::{i18n::Locale, tr}; + let locale = Locale::from_headers(headers); match error { LoadError::Unauthenticated => Redirect::to("/login").into_response(), LoadError::Forbidden => ( axum::http::StatusCode::FORBIDDEN, Html(ui::render_error( - "This account is not allowed to open the admin panel.", + locale, + &tr!(locale, "admin-forbidden"), )), ) .into_response(), LoadError::InvalidSelection(message) => ( axum::http::StatusCode::BAD_REQUEST, - Html(ui::render_error(&message)), + Html(ui::render_error(locale, &message)), ) .into_response(), LoadError::Backend(message) => ( axum::http::StatusCode::BAD_GATEWAY, - Html(ui::render_error(&message)), + Html(ui::render_error(locale, &message)), ) .into_response(), } diff --git a/web/src/pages/admin/admin/state.rs b/web/src/pages/admin/admin/state.rs index 1d7b51af..5d574212 100644 --- a/web/src/pages/admin/admin/state.rs +++ b/web/src/pages/admin/admin/state.rs @@ -41,6 +41,16 @@ impl AdminPageState { pub(crate) fn system_columns(&self) -> Vec<&ColumnView> { self.columns.iter().filter(|column| column.system).collect() } + + /// The "{n} user · {n} system" count line above the column list. + pub(crate) fn column_counts_label(&self, locale: &crate::i18n::Locale) -> String { + crate::tr!( + *locale, + "admin-column-counts", + "user" => self.user_columns().len() as i64, + "system" => self.system_columns().len() as i64, + ) + } } /// One entry in the Profiles pane: a real profile, or the global scope the @@ -55,6 +65,13 @@ pub(crate) struct ProfileView { pub global: bool, } +impl ProfileView { + /// "3 tables" in the request's language, with the correct plural form. + pub(crate) fn table_count_label(&self, locale: &crate::i18n::Locale) -> String { + crate::tr!(*locale, "admin-table-count", "count" => self.table_count as i64) + } +} + #[derive(Debug)] pub(crate) struct TableView { pub name: String, @@ -85,17 +102,24 @@ pub(crate) struct ColumnView { impl ColumnView { /// The badge list rendered under each column name. - pub(crate) fn flags(&self) -> Vec<&'static str> { + pub(crate) fn flags(&self, locale: &crate::i18n::Locale) -> Vec { let mut flags = Vec::new(); if self.system { - flags.push("system"); + flags.push(crate::tr!(*locale, "column-flag-system")); } if self.primary_key { - flags.push("primary key"); + flags.push(crate::tr!(*locale, "column-flag-primary-key")); } - flags.push(if self.nullable { "nullable" } else { "required" }); + flags.push(if self.nullable { + crate::tr!(*locale, "column-flag-nullable") + } else { + crate::tr!(*locale, "column-flag-required") + }); if self.quantity_ledger { - flags.push("quantity ledger"); + flags.push(crate::tr!( + *locale, + "column-flag-quantity-ledger" + )); } flags } diff --git a/web/src/pages/admin/admin/ui.rs b/web/src/pages/admin/admin/ui.rs index dc9eb102..3b50ee5f 100644 --- a/web/src/pages/admin/admin/ui.rs +++ b/web/src/pages/admin/admin/ui.rs @@ -1,5 +1,6 @@ use askama::Template; +use crate::{i18n::Locale, tr}; use crate::ui::{ErrorPage, Nav, render}; use super::state::AdminPageState; @@ -16,6 +17,7 @@ struct AdminPage<'a> { #[derive(Template)] #[template(path = "pages/admin/admin/workspace.html")] struct AdminWorkspace<'a> { + nav: Nav, page: &'a AdminPageState, } @@ -27,13 +29,16 @@ pub(crate) fn render_page(page: &AdminPageState) -> String { } pub(crate) fn render_workspace(page: &AdminPageState) -> String { - render(&AdminWorkspace { page }) + render(&AdminWorkspace { + nav: page.nav.clone(), + page, + }) } -pub(crate) fn render_error(message: &str) -> String { +pub(crate) fn render_error(locale: Locale, message: &str) -> String { render(&ErrorPage { nav: Nav::default(), - heading: "Admin panel unavailable", + heading: &tr!(locale, "admin-unavailable-title"), message, }) } diff --git a/web/src/pages/admin/ecb/logic.rs b/web/src/pages/admin/ecb/logic.rs index f4e94a1a..d94bb831 100644 --- a/web/src/pages/admin/ecb/logic.rs +++ b/web/src/pages/admin/ecb/logic.rs @@ -8,7 +8,7 @@ use axum::{ response::{Html, IntoResponse, Redirect, Response}, }; -use crate::AppState; +use crate::{AppState, {i18n::Locale, tr}}; use super::{loader::load_ecb_page, state::LoadError, ui}; @@ -16,7 +16,7 @@ use super::{loader::load_ecb_page, state::LoadError, ui}; pub(crate) async fn page(State(state): State, headers: HeaderMap) -> Response { match load_ecb_page(state, &headers).await { Ok(page) => Html(ui::render_page(&page)).into_response(), - Err(error) => error_response(error), + Err(error) => error_response(&headers, error), } } @@ -31,22 +31,30 @@ pub(crate) async fn status_fragment( ) -> Response { match load_ecb_page(state, &headers).await { Ok(page) => Html(ui::render_status(&page)).into_response(), - Err(error) => error_response(error), + Err(error) => error_response(&headers, error), } } -fn error_response(error: LoadError) -> Response { +fn error_response(headers: &HeaderMap, error: LoadError) -> Response { match error { LoadError::Unauthenticated => Redirect::to("/login").into_response(), LoadError::Forbidden => ( StatusCode::FORBIDDEN, Html(ui::render_error( - "Reading exchange-rate data is required to open this page.", + Locale::from_headers(headers), + &tr!( + Locale::from_headers(headers), + "ecb-forbidden" + ), )), ) .into_response(), LoadError::Backend(message) => { - (StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response() + ( + StatusCode::BAD_GATEWAY, + Html(ui::render_error(Locale::from_headers(headers), &message)), + ) + .into_response() } } } diff --git a/web/src/pages/admin/ecb/state.rs b/web/src/pages/admin/ecb/state.rs index 382e2b6d..ca80049b 100644 --- a/web/src/pages/admin/ecb/state.rs +++ b/web/src/pages/admin/ecb/state.rs @@ -7,6 +7,7 @@ //! have to decode, and how long a batch took is only knowable by subtracting //! two of them. A string that will not parse is shown as it arrived. +use crate::{i18n::Locale, tr}; use jiff::{SignedDuration, Timestamp}; /// `2026-08-12T17:00:04.512Z` → `2026-08-12 17:00 UTC`. @@ -26,7 +27,7 @@ fn time_of_day(raw: &str) -> String { } /// Coarse duration, largest unit first and never more than two of them. -fn coarse(duration: SignedDuration) -> String { +fn coarse(locale: Locale, duration: SignedDuration) -> String { let seconds = duration.as_secs().max(0); let days = seconds / 86_400; let hours = (seconds % 86_400) / 3600; @@ -42,23 +43,31 @@ fn coarse(duration: SignedDuration) -> String { } else { // A run that finished inside the same second is fast, not instant, and // "0s" reads like a missing measurement. - "<1s".to_string() + tr!(locale, "ecb-less-than-1s") } } -fn elapsed_between(from: &str, to: &str) -> Option { +fn elapsed_between(locale: Locale, from: &str, to: &str) -> Option { let (from, to) = (from.parse::().ok()?, to.parse::().ok()?); - Some(coarse(from.duration_until(to))) + Some(coarse(locale, from.duration_until(to))) } /// How far a timestamp is from now, phrased for whichever side of now it is on. -fn from_now(raw: &str) -> Option { +fn from_now(locale: Locale, raw: &str) -> Option { let timestamp = raw.parse::().ok()?; let now = Timestamp::now(); Some(if timestamp >= now { - format!("in {}", coarse(now.duration_until(timestamp))) + tr!( + locale, + "ecb-in", + "time" => coarse(locale, now.duration_until(timestamp)), + ) } else { - format!("{} ago", coarse(timestamp.duration_until(now))) + tr!( + locale, + "ecb-ago", + "time" => coarse(locale, timestamp.duration_until(now)), + ) }) } @@ -102,8 +111,8 @@ impl ImportBatchView { /// How long the attempt took. A run that suddenly takes minutes instead of /// seconds is the first sign the endpoint is struggling. - pub(crate) fn took(&self) -> Option { - elapsed_between(&self.started_at, self.completed_at.as_deref()?) + pub(crate) fn took(&self, locale: &Locale) -> Option { + elapsed_between(*locale, &self.started_at, self.completed_at.as_deref()?) } /// The row's own date range, collapsed when it covers a single day. @@ -117,10 +126,15 @@ impl ImportBatchView { /// New observations against those the response carried. They differ when a /// batch re-fetched days an earlier one already owned. - pub(crate) fn observations(&self) -> String { + pub(crate) fn observations(&self, locale: &Locale) -> String { match (self.inserted_observation_count, self.observation_count) { (Some(inserted), Some(total)) if inserted == total => inserted.to_string(), - (Some(inserted), Some(total)) => format!("{inserted} new of {total}"), + (Some(inserted), Some(total)) => tr!( + *locale, + "ecb-new-of", + "inserted" => inserted as i64, + "total" => total as i64, + ), (None, Some(total)) => total.to_string(), _ => "—".to_string(), } @@ -144,31 +158,51 @@ pub(crate) struct EcbPageState { impl EcbPageState { /// The headline. Three states, because "behind" and "never ran" need /// different things done about them. - pub(crate) fn headline(&self) -> &'static str { + pub(crate) fn headline(&self) -> String { + let locale = self.nav.locale; if self.healthy { - "Rates are current" + tr!(locale, "ecb-headline-current") } else if self.verified_through_date.is_none() { - "No rates have ever been imported" + tr!(locale, "ecb-headline-never-ran") } else { - "Rates are behind" + tr!(locale, "ecb-headline-behind") } } /// What a reader should do about it, in one sentence. pub(crate) fn explanation(&self) -> String { + let locale = self.nav.locale; if self.healthy { - "Every publication day up to the latest one is verified, so conversions post normally.".to_string() + tr!(locale, "ecb-explanation-current") } else if self.verified_through_date.is_none() { - "Until one succeeds, every conversion in a foreign currency is refused for want of coverage.".to_string() + tr!(locale, "ecb-explanation-never-ran") } else { - format!( - "{} publication {} missing. A conversion dated inside the gap is refused until the importer catches up.", - self.days_behind, - if self.days_behind == 1 { "day is" } else { "days are" } + tr!( + locale, + "ecb-explanation-behind", + "count" => self.days_behind as i64, ) } } + /// The "3 publication days behind" tag in the coverage row. + pub(crate) fn behind_label(&self) -> String { + tr!( + self.nav.locale, + "ecb-behind-count", + "count" => self.days_behind as i64, + ) + } + + /// "2 failed" above the attempts table. + pub(crate) fn failed_count_label(&self) -> String { + tr!( + self.nav.locale, + "ecb-failed-count", + "count" => self.failure_count() as i64, + ) + } + /// The class the status card is painted with. pub(crate) fn health_class(&self) -> &'static str { if self.healthy { "healthy" } else { "unhealthy" } @@ -187,7 +221,7 @@ impl EcbPageState { /// When the scheduler next wakes, and how long that is from now — the /// second half is what tells a reader whether waiting is an option. pub(crate) fn next_import(&self) -> String { - match from_now(&self.next_import_at) { + match from_now(self.nav.locale, &self.next_import_at) { Some(relative) => format!("{} ({relative})", moment(&self.next_import_at)), None => self.next_import_at.clone(), } @@ -198,7 +232,7 @@ impl EcbPageState { pub(crate) fn last_attempt(&self) -> Option { let batch = self.batches.first()?; let when = batch.completed_at.as_deref().unwrap_or(&batch.started_at); - Some(match from_now(when) { + Some(match from_now(self.nav.locale, when) { Some(relative) => format!("{} ({relative})", moment(when)), None => moment(when), }) @@ -209,7 +243,7 @@ impl EcbPageState { pub(crate) fn last_success(&self) -> Option { let batch = self.batches.iter().find(|batch| batch.succeeded())?; let when = batch.completed_at.as_deref().unwrap_or(&batch.started_at); - Some(match from_now(when) { + Some(match from_now(self.nav.locale, when) { Some(relative) => format!("{} ({relative})", moment(when)), None => moment(when), }) diff --git a/web/src/pages/admin/ecb/ui.rs b/web/src/pages/admin/ecb/ui.rs index c9ff43ff..c44e32b6 100644 --- a/web/src/pages/admin/ecb/ui.rs +++ b/web/src/pages/admin/ecb/ui.rs @@ -1,5 +1,6 @@ use askama::Template; +use crate::{i18n::Locale, tr}; use crate::ui::{ErrorPage, Nav, render}; use super::state::EcbPageState; @@ -16,6 +17,7 @@ struct EcbPage<'a> { #[derive(Template)] #[template(path = "pages/admin/ecb/status.html")] struct StatusFragment<'a> { + nav: Nav, page: &'a EcbPageState, } @@ -27,13 +29,16 @@ pub(crate) fn render_page(page: &EcbPageState) -> String { } pub(crate) fn render_status(page: &EcbPageState) -> String { - render(&StatusFragment { page }) + render(&StatusFragment { + nav: page.nav.clone(), + page, + }) } -pub(crate) fn render_error(message: &str) -> String { +pub(crate) fn render_error(locale: Locale, message: &str) -> String { render(&ErrorPage { nav: Nav::default(), - heading: "Exchange rates unavailable", + heading: &tr!(locale, "ecb-unavailable-title"), message, }) } diff --git a/web/src/pages/admin/table_definition/loader.rs b/web/src/pages/admin/table_definition/loader.rs index 4623c037..e88d8cb9 100644 --- a/web/src/pages/admin/table_definition/loader.rs +++ b/web/src/pages/admin/table_definition/loader.rs @@ -108,9 +108,11 @@ pub(crate) async fn load_page( { let missing = std::mem::take(&mut inputs.selection.profile); inputs.selection.table.clear(); - inputs - .error - .get_or_insert(format!("Profile `{missing}` no longer exists.")); + inputs.error.get_or_insert(crate::tr!( + crate::i18n::Locale::from_headers(headers), + "td-err-profile-gone", + "missing" => missing, + )); } // The same two sources the admin panel browses by, so a table it offers is @@ -163,10 +165,13 @@ pub(crate) async fn load_page( .any(|table| table.name == inputs.selection.table) { let missing = std::mem::take(&mut inputs.selection.table); - let scope = inputs.selection.scope_label(); - inputs - .error - .get_or_insert(format!("`{missing}` is not a table of {scope}.")); + let scope = inputs.selection.scope_label(&crate::i18n::Locale::from_headers(headers)); + inputs.error.get_or_insert(crate::tr!( + crate::i18n::Locale::from_headers(headers), + "td-err-not-a-table", + "missing" => missing, + "scope" => scope, + )); } let detail = match inputs.selection.has_table() { diff --git a/web/src/pages/admin/table_definition/logic.rs b/web/src/pages/admin/table_definition/logic.rs index 5706ac85..61bbefe2 100644 --- a/web/src/pages/admin/table_definition/logic.rs +++ b/web/src/pages/admin/table_definition/logic.rs @@ -25,6 +25,7 @@ use crate::{ AddTableColumnsRequest, CopyProfileRequest, CreateInvoiceTemplateTableRequest, DeleteTableRequest, RenameColumnAliasRequest, }, + {i18n::Locale, tr}, schema::{ColumnForm, proto_columns}, services::{authenticated_request, reject_cross_site}, }; @@ -144,7 +145,7 @@ async fn show(state: AppState, headers: HeaderMap, inputs: PageInputs, page: Pag loaded.active = page.name(); Html(page.render_page(&loaded)).into_response() } - Err(error) => load_error_response(error), + Err(error) => load_error_response(&headers, error), } } @@ -169,20 +170,21 @@ pub(crate) async fn update_columns( let mut definitions = state.definitions.clone(); let catalog = match loader::load_column_catalog(&mut definitions, &headers).await { Ok(catalog) => catalog, - Err(error) => return load_error_response(error), + Err(error) => return load_error_response(&headers, error), }; let mut inputs = PageInputs::for_selection(selection); inputs.columns = form.to_draft(catalog, false); let index = form.index.unwrap_or(0); + let locale = Locale::from_headers(&headers); match form.action.as_str() { - "add-column" => match inputs.columns.add_from_inputs() { + "add-column" => match inputs.columns.add_from_inputs(locale) { Ok(status) => inputs.status = Some(status), Err(message) => inputs.error = Some(message), }, "remove-column" => { - if let Err(message) = inputs.columns.remove(index) { + if let Err(message) = inputs.columns.remove(locale, index) { inputs.error = Some(message); } } @@ -195,7 +197,7 @@ pub(crate) async fn update_columns( loaded.active = Page::Columns.name(); Html(ui::render_column_panel(&loaded)).into_response() } - Err(error) => load_error_response(error), + Err(error) => load_error_response(&headers, error), } } @@ -215,29 +217,43 @@ pub(crate) async fn add_columns( let mut definitions = state.definitions.clone(); let catalog = match loader::load_column_catalog(&mut definitions, &headers).await { Ok(catalog) => catalog, - Err(error) => return load_error_response(error), + Err(error) => return load_error_response(&headers, error), }; let mut inputs = PageInputs::for_selection(selection); inputs.columns = form.to_draft(catalog.clone(), false); if !inputs.selection.has_table() { - return refuse(state, headers, inputs, Page::Columns, "Select a table first.".to_string()) - .await; - } - if inputs.columns.is_empty() { + let message = tr!( + Locale::from_headers(&headers), + "td-err-select-table-first" + ); return refuse( state, headers, inputs, Page::Columns, - "Describe at least one column before adding.".to_string(), + message, + ) + .await; + } + if inputs.columns.is_empty() { + let message = tr!( + Locale::from_headers(&headers), + "td-err-describe-column" + ); + return refuse( + state, + headers, + inputs, + Page::Columns, + message, ) .await; } // The same checks the server runs, applied to a draft that may have been // rebuilt from a posted form rather than through the panel. - if let Err(message) = inputs.columns.validate() { + if let Err(message) = inputs.columns.validate(Locale::from_headers(&headers)) { return refuse(state, headers, inputs, Page::Columns, message).await; } @@ -259,10 +275,11 @@ pub(crate) async fn add_columns( Ok(response) if response.get_ref().success => { let added = inputs.columns.added.len(); inputs.sql = Some(response.into_inner().sql); - inputs.status = Some(format!( - "{added} column{} added to `{}`.", - if added == 1 { "" } else { "s" }, - inputs.selection.table + inputs.status = Some(tr!( + Locale::from_headers(&headers), + "td-columns-added", + "count" => added as i64, + "table" => inputs.selection.table.clone(), )); // The columns are the table's now, so the panel starts empty. inputs.columns = crate::schema::ColumnDraft::for_append(catalog); @@ -271,7 +288,10 @@ pub(crate) async fn add_columns( Ok(response) => { let message = response.into_inner().sql; let message = if message.is_empty() { - "The backend did not add the columns.".to_string() + tr!( + Locale::from_headers(&headers), + "td-err-backend-no-columns" + ) } else { message }; @@ -300,12 +320,16 @@ pub(crate) async fn rename_column( inputs.rename = form.clone(); if form.old_column_name.is_empty() || form.new_column_name.trim().is_empty() { + let message = tr!( + Locale::from_headers(&headers), + "td-err-choose-rename" + ); return refuse( state, headers, inputs, Page::Columns, - "Choose a column and type its new name.".to_string(), + message, ) .await; } @@ -334,7 +358,10 @@ pub(crate) async fn rename_column( Ok(response) => { let message = response.into_inner().message; let message = if message.is_empty() { - "The backend did not rename the column.".to_string() + tr!( + Locale::from_headers(&headers), + "td-err-backend-no-rename" + ) } else { message }; @@ -367,12 +394,16 @@ pub(crate) async fn delete_table( // The typed name is the whole guard: the drop is CASCADE, and it takes the // profile with it when this was its last table. if form.confirm_table_name.trim() != form.table || form.table.is_empty() { + let message = tr!( + Locale::from_headers(&headers), + "td-err-type-name" + ); return refuse( state, headers, inputs, Page::Delete, - "Type the table's name exactly to confirm the deletion.".to_string(), + message, ) .await; } @@ -404,7 +435,10 @@ pub(crate) async fn delete_table( Ok(response) => { let message = response.into_inner().message; let message = if message.is_empty() { - "The backend did not delete the table.".to_string() + tr!( + Locale::from_headers(&headers), + "td-err-backend-no-delete" + ) } else { message }; @@ -433,12 +467,16 @@ pub(crate) async fn copy_profile( inputs.copy = form.clone(); if form.target_profile_name.trim().is_empty() { + let message = tr!( + Locale::from_headers(&headers), + "td-err-name-profile" + ); return refuse( state, headers, inputs, Page::Copy, - "Name the profile to copy into.".to_string(), + message, ) .await; } @@ -456,9 +494,12 @@ pub(crate) async fn copy_profile( match definitions.copy_profile(request).await { Ok(response) if response.get_ref().success => { let response = response.into_inner(); - inputs.status = Some(format!( - "{} — {} table(s) and {} script(s) copied.", - response.message, response.tables_copied, response.scripts_copied + inputs.status = Some(tr!( + Locale::from_headers(&headers), + "td-profile-copied", + "message" => response.message.clone(), + "tables" => response.tables_copied as i64, + "scripts" => response.scripts_copied as i64, )); inputs.copy = CopyForm { profile: form.profile, @@ -469,7 +510,10 @@ pub(crate) async fn copy_profile( Ok(response) => { let message = response.into_inner().message; let message = if message.is_empty() { - "The backend did not copy the profile.".to_string() + tr!( + Locale::from_headers(&headers), + "td-err-backend-no-copy" + ) } else { message }; @@ -496,12 +540,16 @@ pub(crate) async fn create_from_invoice_template( inputs.invoice = form.clone(); if form.table_name.trim().is_empty() || form.typst_source.trim().is_empty() { + let message = tr!( + Locale::from_headers(&headers), + "td-err-template-required" + ); return refuse( state, headers, inputs, Page::Template, - "A table name and the template's source are both required.".to_string(), + message, ) .await; } @@ -520,9 +568,10 @@ pub(crate) async fn create_from_invoice_template( match definitions.create_invoice_template_table(request).await { Ok(response) if response.get_ref().success => { let response = response.into_inner(); - inputs.status = Some(format!( - "{} table(s) created from the template.", - response.tables.len() + inputs.status = Some(tr!( + Locale::from_headers(&headers), + "td-template-tables-created", + "count" => response.tables.len() as i64, )); inputs.generated = response .tables @@ -540,12 +589,16 @@ pub(crate) async fn create_from_invoice_template( respond(state, headers, inputs, Page::Template, StatusCode::OK).await } Ok(_) => { + let message = tr!( + Locale::from_headers(&headers), + "td-err-backend-no-template" + ); refuse( state, headers, inputs, Page::Template, - "The backend did not create the template's tables.".to_string(), + message, ) .await } @@ -568,7 +621,7 @@ async fn respond( loaded.active = page.name(); (status, Html(page.render_fragment(&loaded))).into_response() } - Err(error) => load_error_response(error), + Err(error) => load_error_response(&headers, error), } } @@ -584,19 +637,23 @@ async fn refuse( respond(state, headers, inputs, page, StatusCode::UNPROCESSABLE_ENTITY).await } -fn load_error_response(error: LoadError) -> Response { +fn load_error_response(headers: &HeaderMap, error: LoadError) -> Response { match error { LoadError::Unauthenticated => Redirect::to("/login").into_response(), LoadError::Forbidden => ( StatusCode::FORBIDDEN, Html(ui::render_load_error( - "Table-management permission is required.", + Locale::from_headers(headers), + &tr!( + Locale::from_headers(headers), + "td-err-permission" + ), )), ) .into_response(), LoadError::Backend(message) => ( StatusCode::BAD_GATEWAY, - Html(ui::render_load_error(&message)), + Html(ui::render_load_error(Locale::from_headers(headers), &message)), ) .into_response(), } diff --git a/web/src/pages/admin/table_definition/state.rs b/web/src/pages/admin/table_definition/state.rs index 3ae1683d..d71f3c1a 100644 --- a/web/src/pages/admin/table_definition/state.rs +++ b/web/src/pages/admin/table_definition/state.rs @@ -50,9 +50,9 @@ impl Selection { /// What the heading calls the scope, so the global one is not shown by its /// sentinel name. - pub(crate) fn scope_label(&self) -> String { + pub(crate) fn scope_label(&self, locale: &crate::i18n::Locale) -> String { if self.is_global() { - "Global — all profiles".to_string() + crate::tr!(*locale, "td-global-label") } else { self.profile.clone() } @@ -115,24 +115,31 @@ pub(crate) struct DetailColumn { impl DetailColumn { /// The badge list rendered under each column name. - pub(crate) fn flags(&self) -> Vec { + pub(crate) fn flags(&self, locale: &crate::i18n::Locale) -> Vec { let mut flags = Vec::new(); if !self.currency.is_empty() { flags.push(self.currency.clone()); } if self.rounded { - flags.push("half-up".to_string()); + flags.push(crate::tr!(*locale, "column-flag-half-up")); } if self.quantity_ledger { - flags.push("quantity ledger".to_string()); + flags.push(crate::tr!( + *locale, + "column-flag-quantity-ledger" + )); } if self.read_only { - flags.push("read only".to_string()); + flags.push(crate::tr!(*locale, "column-flag-read-only")); } if !self.generated_from.is_empty() { - flags.push(format!("generated from {}", self.generated_from)); + flags.push(crate::tr!( + *locale, + "column-flag-generated-from", + "source" => self.generated_from.clone(), + )); } else if self.generated { - flags.push("generated".to_string()); + flags.push(crate::tr!(*locale, "column-flag-generated")); } flags } diff --git a/web/src/pages/admin/table_definition/ui.rs b/web/src/pages/admin/table_definition/ui.rs index c97cf7b4..89dd2d30 100644 --- a/web/src/pages/admin/table_definition/ui.rs +++ b/web/src/pages/admin/table_definition/ui.rs @@ -8,6 +8,7 @@ use askama::Template; use crate::{ + {i18n::Locale, tr}, schema::CURRENCY_CODES, ui::{Alert, Nav, render}, }; @@ -33,6 +34,7 @@ struct ColumnsPage<'a> { #[derive(Template)] #[template(path = "pages/admin/table_definition/columns_panel.html")] struct ColumnsFragment<'a> { + nav: Nav, page: &'a TableDefinitionPageState, column_types: Vec, temporal_types: Vec, @@ -46,6 +48,7 @@ struct ColumnsFragment<'a> { #[derive(Template)] #[template(path = "pages/admin/table_definition/column_panel.html")] struct ColumnPanelFragment<'a> { + nav: Nav, page: &'a TableDefinitionPageState, column_types: Vec, temporal_types: Vec, @@ -66,6 +69,7 @@ struct DeletePage<'a> { #[derive(Template)] #[template(path = "pages/admin/table_definition/delete_panel.html")] struct DeleteFragment<'a> { + nav: Nav, page: &'a TableDefinitionPageState, } @@ -80,6 +84,7 @@ struct CopyPage<'a> { #[derive(Template)] #[template(path = "pages/admin/table_definition/copy_panel.html")] struct CopyFragment<'a> { + nav: Nav, page: &'a TableDefinitionPageState, } @@ -94,6 +99,7 @@ struct TemplatePage<'a> { #[derive(Template)] #[template(path = "pages/admin/table_definition/from_template_panel.html")] struct TemplateFragment<'a> { + nav: Nav, page: &'a TableDefinitionPageState, } @@ -121,6 +127,7 @@ pub(crate) fn render_columns_page(page: &TableDefinitionPageState) -> String { pub(crate) fn render_columns_fragment(page: &TableDefinitionPageState) -> String { render(&ColumnsFragment { + nav: page.nav.clone(), page, column_types: page.columns.offered_types(), temporal_types: page.columns.temporal_types(), @@ -131,6 +138,7 @@ pub(crate) fn render_columns_fragment(page: &TableDefinitionPageState) -> String pub(crate) fn render_column_panel(page: &TableDefinitionPageState) -> String { render(&ColumnPanelFragment { + nav: page.nav.clone(), page, column_types: page.columns.offered_types(), temporal_types: page.columns.temporal_types(), @@ -147,7 +155,10 @@ pub(crate) fn render_delete_page(page: &TableDefinitionPageState) -> String { } pub(crate) fn render_delete_fragment(page: &TableDefinitionPageState) -> String { - render(&DeleteFragment { page }) + render(&DeleteFragment { + nav: page.nav.clone(), + page, + }) } pub(crate) fn render_copy_page(page: &TableDefinitionPageState) -> String { @@ -158,7 +169,10 @@ pub(crate) fn render_copy_page(page: &TableDefinitionPageState) -> String { } pub(crate) fn render_copy_fragment(page: &TableDefinitionPageState) -> String { - render(&CopyFragment { page }) + render(&CopyFragment { + nav: page.nav.clone(), + page, + }) } pub(crate) fn render_template_page(page: &TableDefinitionPageState) -> String { @@ -169,7 +183,10 @@ pub(crate) fn render_template_page(page: &TableDefinitionPageState) -> String { } pub(crate) fn render_template_fragment(page: &TableDefinitionPageState) -> String { - render(&TemplateFragment { page }) + render(&TemplateFragment { + nav: page.nav.clone(), + page, + }) } pub(crate) fn render_history_page(page: &TableDefinitionPageState) -> String { @@ -181,8 +198,12 @@ pub(crate) fn render_history_page(page: &TableDefinitionPageState) -> String { /// Used when a page itself cannot be loaded. There is nothing left to render, /// so the dialog is what tells the user why. -pub(crate) fn render_load_error(message: &str) -> String { - render(&Alert::error("Table definition unavailable", message)) +pub(crate) fn render_load_error(locale: Locale, message: &str) -> String { + render(&Alert::error( + locale, + &tr!(locale, "td-unavailable-title"), + message, + )) } #[cfg(test)] diff --git a/web/src/pages/analytics/logic.rs b/web/src/pages/analytics/logic.rs index 819502be..0681c7cf 100644 --- a/web/src/pages/analytics/logic.rs +++ b/web/src/pages/analytics/logic.rs @@ -7,6 +7,7 @@ use axum::{ use crate::{ AppState, + {i18n::Locale, tr}, ui::{Notice, render}, }; @@ -28,9 +29,18 @@ pub(crate) async fn load_profiles( headers: HeaderMap, ) -> Html { match loader::load_profiles(state, &headers).await { - Ok(profiles) => Html(ui::render_profile_options(&profiles)), - Err(LoadError::Unauthenticated) => Html(ui::render_profile_options_error("Sign in to list the profiles you may read.")), - Err(LoadError::Backend(message)) => Html(ui::render_profile_options_error(&message)), + Ok(profiles) => Html(ui::render_profile_options( + crate::ui::Nav::new(&headers, ""), + &profiles, + )), + Err(LoadError::Unauthenticated) => Html(ui::render_profile_options_error( + crate::ui::Nav::new(&headers, ""), + &tr!(Locale::from_headers(&headers), "analytics-sign-in"), + )), + Err(LoadError::Backend(message)) => Html(ui::render_profile_options_error( + crate::ui::Nav::new(&headers, ""), + &message, + )), } } @@ -40,11 +50,21 @@ pub(crate) async fn load_catalog( Form(input): Form, ) -> Response { if input.profile_name.trim().is_empty() { - return notice(Notice::error("Enter a profile name to load its schema")); + return notice(Notice::error( + Locale::from_headers(&headers), + &tr!( + Locale::from_headers(&headers), + "analytics-err-profile-required" + ), + )); } match loader::load_catalog(state, &headers, input.profile_name).await { - Ok(catalog) => Html(ui::render_catalog(&catalog)).into_response(), - Err(error) => load_error(error, "loading the schema"), + Ok(catalog) => Html(ui::render_catalog( + crate::ui::Nav::new(&headers, ""), + &catalog, + )) + .into_response(), + Err(error) => load_error(&headers, error, "analytics-action-loading-schema"), } } @@ -54,7 +74,10 @@ pub(crate) async fn run_query( Form(input): Form, ) -> Response { if input.profile_name.trim().is_empty() || input.sql.trim().is_empty() { - return notice(Notice::error("Profile name and SQL are required")); + return notice(Notice::error( + Locale::from_headers(&headers), + &tr!(Locale::from_headers(&headers), "analytics-err-input-required"), + )); } let result = loader::run_query( state, @@ -65,20 +88,26 @@ pub(crate) async fn run_query( ) .await; match result { - Ok(output) => match ui::render_result(&output, &input.chart_type) { + Ok(output) => match ui::render_result(Locale::from_headers(&headers), &output, &input.chart_type) { Ok(html) => Html(html).into_response(), - Err(message) => notice(Notice::error(&message)), + Err(message) => notice(Notice::error(Locale::from_headers(&headers), &message)), }, - Err(error) => load_error(error, "running a query"), + Err(error) => load_error(&headers, error, "analytics-action-running-query"), } } -fn load_error(error: LoadError, action: &str) -> Response { +fn load_error(headers: &HeaderMap, error: LoadError, action_key: &str) -> Response { + let locale = Locale::from_headers(headers); match error { - LoadError::Unauthenticated => notice(Notice::login_required(&format!( - "You are not signed in for {action}." - ))), - LoadError::Backend(message) => notice(Notice::error(&message)), + LoadError::Unauthenticated => notice(Notice::login_required( + locale, + &tr!( + locale, + "analytics-not-signed-in", + "action" => tr!(locale, action_key), + ), + )), + LoadError::Backend(message) => notice(Notice::error(locale, &message)), } } diff --git a/web/src/pages/analytics/ui.rs b/web/src/pages/analytics/ui.rs index b79b89f2..3e021d82 100644 --- a/web/src/pages/analytics/ui.rs +++ b/web/src/pages/analytics/ui.rs @@ -1,6 +1,7 @@ use askama::Template; use serde_json::{Value, json}; +use crate::{i18n::Locale, tr}; use crate::ui::{Nav, render}; use super::state::{CatalogView, ColumnOutput, ProfileOption, QueryOutput}; @@ -16,6 +17,7 @@ struct AnalyticsPage { #[derive(Template)] #[template(path = "pages/analytics/profile_options.html")] struct ProfileOptions<'a> { + nav: Nav, profiles: &'a [ProfileOption], error: Option<&'a str>, } @@ -24,6 +26,7 @@ struct ProfileOptions<'a> { #[derive(Template)] #[template(path = "pages/analytics/catalog.html")] struct CatalogFragment<'a> { + nav: Nav, tables: &'a [super::state::CatalogTableView], llm_context: &'a str, } @@ -35,9 +38,9 @@ struct CatalogFragment<'a> { struct QueryResult<'a> { columns: &'a [ColumnOutput], rows: Vec>, - row_count: u64, - elapsed_ms: u64, - truncated: bool, + /// "3 rows in 12 ms (truncated)" in the request's language, built here + /// because the count needs Fluent plural selection. + meta: String, chart_option: Option, } @@ -45,22 +48,25 @@ pub(crate) fn render_page(nav: Nav) -> String { render(&AnalyticsPage { nav }) } -pub(crate) fn render_profile_options(profiles: &[ProfileOption]) -> String { +pub(crate) fn render_profile_options(nav: Nav, profiles: &[ProfileOption]) -> String { render(&ProfileOptions { + nav, profiles, error: None, }) } -pub(crate) fn render_profile_options_error(message: &str) -> String { +pub(crate) fn render_profile_options_error(nav: Nav, message: &str) -> String { render(&ProfileOptions { + nav, profiles: &[], error: Some(message), }) } -pub(crate) fn render_catalog(catalog: &CatalogView) -> String { +pub(crate) fn render_catalog(nav: Nav, catalog: &CatalogView) -> String { render(&CatalogFragment { + nav, tables: &catalog.tables, llm_context: &catalog.llm_context, }) @@ -68,10 +74,14 @@ pub(crate) fn render_catalog(catalog: &CatalogView) -> String { /// `Err` carries a message for the caller to show as a notice — the only /// failure is a chart type that the result shape cannot satisfy. -pub(crate) fn render_result(result: &QueryOutput, chart_type: &str) -> Result { +pub(crate) fn render_result( + locale: Locale, + result: &QueryOutput, + chart_type: &str, +) -> Result { let chart_option = match chart_type { "table" => None, - _ => Some(chart_option(result, chart_type)?.to_string()), + _ => Some(chart_option(locale, result, chart_type)?.to_string()), }; Ok(render(&QueryResult { columns: &result.columns, @@ -80,16 +90,28 @@ pub(crate) fn render_result(result: &QueryOutput, chart_type: &str) -> Result result.row_count as i64, + "elapsed" => result.elapsed_ms as i64, + ) + } else { + tr!( + locale, + "analytics-result-meta", + "rows" => result.row_count as i64, + "elapsed" => result.elapsed_ms as i64, + ) + }, chart_option, })) } -fn chart_option(result: &QueryOutput, chart_type: &str) -> Result { +fn chart_option(locale: Locale, result: &QueryOutput, chart_type: &str) -> Result { if result.columns.len() < 2 { - return Err("Charts require at least two result columns".to_string()); + return Err(tr!(locale, "analytics-err-chart-columns")); } let names = result .columns @@ -147,7 +169,7 @@ fn chart_option(result: &QueryOutput, chart_type: &str) -> Result "series": series, }) } - _ => return Err("Unknown chart type".to_string()), + _ => return Err(tr!(locale, "analytics-err-chart-type")), }) } diff --git a/web/src/pages/import_export/common/csv.rs b/web/src/pages/import_export/common/csv.rs index 6d79ad78..49bd60d7 100644 --- a/web/src/pages/import_export/common/csv.rs +++ b/web/src/pages/import_export/common/csv.rs @@ -1,4 +1,6 @@ -pub(crate) fn parse_csv(input: &str) -> Result>, String> { +use crate::{i18n::Locale, tr}; + +pub(crate) fn parse_csv(locale: Locale, input: &str) -> Result>, String> { let mut rows = Vec::new(); let mut row = Vec::new(); let mut field = String::new(); @@ -27,7 +29,7 @@ pub(crate) fn parse_csv(input: &str) -> Result>, String> { } } if quoted { - return Err("CSV contains an unterminated quoted value.".to_string()); + return Err(tr!(locale, "import-err-unterminated-quote")); } if field.ends_with('\r') { field.pop(); @@ -39,7 +41,7 @@ pub(crate) fn parse_csv(input: &str) -> Result>, String> { } } if rows.is_empty() { - Err("CSV is empty.".to_string()) + Err(tr!(locale, "import-err-empty")) } else { Ok(rows) } diff --git a/web/src/pages/import_export/common/schema.rs b/web/src/pages/import_export/common/schema.rs index 4644e37b..c4e5d2a7 100644 --- a/web/src/pages/import_export/common/schema.rs +++ b/web/src/pages/import_export/common/schema.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use prost_types::{NullValue, Value, value::Kind}; +use crate::{i18n::Locale, tr}; + use crate::definitions::table_structure::TableStructureResponse; pub(crate) fn exportable_columns(schema: &TableStructureResponse) -> Vec { @@ -26,7 +28,7 @@ pub(crate) fn column_types(schema: &TableStructureResponse) -> HashMap Result { +pub(crate) fn csv_value(locale: Locale, raw: &str, data_type: &str) -> Result { let raw = raw.trim(); if raw.is_empty() { return Ok(Value { @@ -38,7 +40,13 @@ pub(crate) fn csv_value(raw: &str, data_type: &str) -> Result { match raw.to_ascii_lowercase().as_str() { "true" | "t" | "1" | "yes" | "y" => Kind::BoolValue(true), "false" | "f" | "0" | "no" | "n" => Kind::BoolValue(false), - _ => return Err(format!("Invalid boolean value '{raw}'")), + _ => { + return Err(tr!( + locale, + "import-err-bad-boolean", + "value" => raw.to_string(), + )) + } } } else if matches!( normalized.as_str(), @@ -46,7 +54,13 @@ pub(crate) fn csv_value(raw: &str, data_type: &str) -> Result { ) { let integer = raw .parse::() - .map_err(|_| format!("Invalid integer value '{raw}'"))?; + .map_err(|_| { + tr!( + locale, + "import-err-bad-integer", + "value" => raw.to_string(), + ) + })?; Kind::NumberValue(integer as f64) } else { Kind::StringValue(raw.to_string()) diff --git a/web/src/pages/import_export/export/logic.rs b/web/src/pages/import_export/export/logic.rs index 666fb0c3..dc51ff87 100644 --- a/web/src/pages/import_export/export/logic.rs +++ b/web/src/pages/import_export/export/logic.rs @@ -11,6 +11,7 @@ use crate::{ table_structure::GetTableStructureRequest, tables_data::{GetTableDataByPositionRequest, GetTableDataCountRequest}, }, + {i18n::Locale, tr}, services::authenticated_request, }; @@ -34,7 +35,7 @@ struct ExportTable { pub(crate) async fn export_page(State(state): State, headers: HeaderMap) -> Response { match load_page(state, &headers).await { Ok(page) => Html(ui::render_page(&page)).into_response(), - Err(error) => load_error(error), + Err(error) => load_error(&headers, error), } } @@ -45,20 +46,46 @@ pub(crate) async fn export_csv( ) -> Response { let catalog = match load_page(state.clone(), &headers).await { Ok(page) => page.catalog, - Err(error) => return load_error(error), + Err(error) => return load_error(&headers, error), }; if cross_site(&headers) { return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response(); } - let (profile_name, table_names) = match form.targets() { + let (profile_name, table_names) = match form.targets(Locale::from_headers(&headers)) { Ok(targets) => targets, - Err(message) => return (StatusCode::BAD_REQUEST, Html(ui::render_error(&message))).into_response(), + Err(message) => { + return ( + StatusCode::BAD_REQUEST, + Html(ui::render_error( + Locale::from_headers(&headers), + &message, + )), + ) + .into_response() + } }; let Some(profile) = catalog.profiles.iter().find(|profile| profile.name == profile_name) else { - return (StatusCode::BAD_REQUEST, Html(ui::render_error("Unknown profile."))).into_response(); + return ( + StatusCode::BAD_REQUEST, + Html(ui::render_error( + Locale::from_headers(&headers), + &tr!(Locale::from_headers(&headers), "export-err-unknown-profile"), + )), + ) + .into_response(); }; if table_names.iter().any(|name| !profile.tables.contains(name)) { - return (StatusCode::BAD_REQUEST, Html(ui::render_error("One or more tables do not belong to the selected profile."))).into_response(); + return ( + StatusCode::BAD_REQUEST, + Html(ui::render_error( + Locale::from_headers(&headers), + &tr!( + Locale::from_headers(&headers), + "export-err-tables-not-in-profile" + ), + )), + ) + .into_response(); } let mut tables = Vec::new(); @@ -76,10 +103,16 @@ pub(crate) async fn export_csv( .await { Ok(response) => response.into_inner().table_structures.remove(table_name), - Err(error) => return backend_error(error.message()), + Err(error) => return backend_error(&headers, error.message()), }; let Some(structure) = structure else { - return backend_error("The backend omitted a requested table structure."); + return backend_error( + &headers, + &tr!( + Locale::from_headers(&headers), + "export-err-missing-structure" + ), + ); }; let count_request = GetTableDataCountRequest { profile_name: profile_name.clone(), @@ -94,10 +127,13 @@ pub(crate) async fn export_csv( .await { Ok(response) => response.into_inner().count, - Err(error) => return backend_error(error.message()), + Err(error) => return backend_error(&headers, error.message()), }; let Ok(count) = u64::try_from(count) else { - return backend_error("The backend returned a negative row count."); + return backend_error( + &headers, + &tr!(Locale::from_headers(&headers), "export-err-negative-count"), + ); }; tables.push(ExportTable { name: table_name.clone(), columns: exportable_columns(&structure), count }); } @@ -111,7 +147,10 @@ pub(crate) async fn export_csv( write_record(&mut csv, &headers_row); let max_count = tables.iter().map(|table| table.count).max().unwrap_or(0); if max_count > i32::MAX as u64 { - return backend_error("The export exceeds the supported row-position range."); + return backend_error( + &headers, + &tr!(Locale::from_headers(&headers), "export-err-range"), + ); } for position in 1..=max_count { let mut row = Vec::new(); @@ -131,7 +170,7 @@ pub(crate) async fn export_csv( Err(_) => return Redirect::to("/login").into_response(), }).await { Ok(response) => response.into_inner(), - Err(error) => return backend_error(error.message()), + Err(error) => return backend_error(&headers, error.message()), }; row.extend(table.columns.iter().map(|column| response.data.get(column).cloned().unwrap_or_default())); } @@ -147,16 +186,30 @@ pub(crate) async fn export_csv( response } -fn load_error(error: LoadError) -> Response { +fn load_error(headers: &HeaderMap, error: LoadError) -> Response { match error { LoadError::Unauthenticated => Redirect::to("/login").into_response(), - LoadError::Forbidden => (StatusCode::FORBIDDEN, Html(ui::render_error("Read permission is required for at least one table."))).into_response(), - LoadError::Backend(message) => backend_error(&message), + LoadError::Forbidden => ( + StatusCode::FORBIDDEN, + Html(ui::render_error( + Locale::from_headers(headers), + &tr!( + Locale::from_headers(headers), + "export-err-permission" + ), + )), + ) + .into_response(), + LoadError::Backend(message) => backend_error(headers, &message), } } -fn backend_error(message: &str) -> Response { - (StatusCode::BAD_GATEWAY, Html(ui::render_error(message))).into_response() +fn backend_error(headers: &HeaderMap, message: &str) -> Response { + ( + StatusCode::BAD_GATEWAY, + Html(ui::render_error(Locale::from_headers(headers), message)), + ) + .into_response() } fn cross_site(headers: &HeaderMap) -> bool { diff --git a/web/src/pages/import_export/export/state.rs b/web/src/pages/import_export/export/state.rs index b2f3d598..0ea021fd 100644 --- a/web/src/pages/import_export/export/state.rs +++ b/web/src/pages/import_export/export/state.rs @@ -1,3 +1,5 @@ +use crate::{i18n::Locale, tr}; + #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct ExportForm { #[serde(default)] @@ -12,10 +14,10 @@ pub(crate) struct ExportPageState { } impl ExportForm { - pub(crate) fn targets(&self) -> Result<(String, Vec), String> { + pub(crate) fn targets(&self, locale: Locale) -> Result<(String, Vec), String> { let profile = self.profile_name.trim(); if profile.is_empty() { - return Err("Select a profile.".to_string()); + return Err(tr!(locale, "export-err-select-profile")); } let tables = self .table_names @@ -25,7 +27,7 @@ impl ExportForm { .map(str::to_string) .collect::>(); if tables.is_empty() { - return Err("Enter at least one table.".to_string()); + return Err(tr!(locale, "export-err-tables-required")); } Ok((profile.to_string(), tables)) } diff --git a/web/src/pages/import_export/export/ui.rs b/web/src/pages/import_export/export/ui.rs index f5ac8851..d36be1e7 100644 --- a/web/src/pages/import_export/export/ui.rs +++ b/web/src/pages/import_export/export/ui.rs @@ -1,5 +1,6 @@ use askama::Template; +use crate::{i18n::Locale, tr}; use crate::ui::{Alert, Nav, render}; use super::state::ExportPageState; @@ -20,6 +21,10 @@ pub(crate) fn render_page(page: &ExportPageState) -> String { } /// POST /admin/export.csv — shown only when the download cannot be produced. -pub(crate) fn render_error(message: &str) -> String { - render(&Alert::error("Could not export CSV", message)) +pub(crate) fn render_error(locale: Locale, message: &str) -> String { + render(&Alert::error( + locale, + &tr!(locale, "export-error-title"), + message, + )) } diff --git a/web/src/pages/import_export/import/logic.rs b/web/src/pages/import_export/import/logic.rs index 286a5b2c..e9919c41 100644 --- a/web/src/pages/import_export/import/logic.rs +++ b/web/src/pages/import_export/import/logic.rs @@ -13,6 +13,7 @@ use crate::{ table_structure::GetTableStructureRequest, tables_data::{PostTableDataBulkRequest, PostTableDataBulkRow}, }, + {i18n::Locale, tr}, services::authenticated_request, }; @@ -34,7 +35,7 @@ struct ImportTable { } pub(crate) async fn import_page(State(state): State, headers: HeaderMap) -> Response { - render_loaded(load_page(state, &headers, ImportForm::default(), None).await) + render_loaded(&headers, load_page(state, &headers, ImportForm::default(), None).await) } pub(crate) async fn import_csv( @@ -45,28 +46,68 @@ pub(crate) async fn import_csv( let submitted = form.clone(); let page = match load_page(state.clone(), &headers, submitted.clone(), None).await { Ok(page) => page, - Err(error) => return load_error(error), + Err(error) => return load_error(&headers, error), }; if cross_site(&headers) { return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response(); } - let (profile_name, table_names) = match form.targets() { + let (profile_name, table_names) = match form.targets(Locale::from_headers(&headers)) { Ok(targets) => targets, - Err(message) => return render_loaded(load_page(state, &headers, submitted, Some(message)).await), + Err(message) => { + return render_loaded( + &headers, + load_page(state, &headers, submitted, Some(message)).await, + ) + } }; let Some(profile) = page.catalog.profiles.iter().find(|profile| profile.name == profile_name) else { - return render_loaded(load_page(state, &headers, submitted, Some("Unknown profile.".to_string())).await); + return render_loaded( + &headers, + load_page( + state, + &headers, + submitted, + Some(tr!( + Locale::from_headers(&headers), + "import-err-unknown-profile" + )), + ) + .await, + ); }; if table_names.iter().any(|name| !profile.tables.contains(name)) { - return render_loaded(load_page(state, &headers, submitted, Some("One or more target tables do not belong to the profile.".to_string())).await); + return render_loaded( + &headers, + load_page( + state, + &headers, + submitted, + Some(tr!( + Locale::from_headers(&headers), + "import-err-tables-not-in-profile" + )), + ) + .await, + ); } - let rows = match parse_csv(&form.csv_data) { + let rows = match parse_csv(Locale::from_headers(&headers), &form.csv_data) { Ok(rows) => rows, - Err(message) => return render_loaded(load_page(state, &headers, submitted, Some(message)).await), + Err(message) => { + return render_loaded( + &headers, + load_page(state, &headers, submitted, Some(message)).await, + ) + } }; - let (table_headers, columns, data_rows) = match split_headers(rows, &table_names) { + let (table_headers, columns, data_rows) = + match split_headers(Locale::from_headers(&headers), rows, &table_names) { Ok(parts) => parts, - Err(message) => return render_loaded(load_page(state, &headers, submitted, Some(message)).await), + Err(message) => { + return render_loaded( + &headers, + load_page(state, &headers, submitted, Some(message)).await, + ) + } }; let mut tables = Vec::new(); @@ -81,10 +122,16 @@ pub(crate) async fn import_csv( Err(_) => return Redirect::to("/login").into_response(), }).await { Ok(response) => response.into_inner().table_structures.remove(table_name), - Err(error) => return backend_error(error.message()), + Err(error) => return backend_error(&headers, error.message().to_string()), }; let Some(structure) = structure else { - return backend_error("The backend omitted a requested table structure."); + return backend_error( + &headers, + tr!( + Locale::from_headers(&headers), + "import-err-missing-structure" + ), + ); }; tables.push(ImportTable { name: table_name.clone(), @@ -106,22 +153,65 @@ pub(crate) async fn import_csv( .map(|(index, column)| (index, column.clone())) .collect::>(); if positions.is_empty() { - return render_loaded(load_page(state, &headers, submitted, Some(format!("CSV has no importable columns for table '{}'.", table.name))).await); + return render_loaded( + &headers, + load_page( + state, + &headers, + submitted, + Some(tr!( + Locale::from_headers(&headers), + "import-err-no-importable-columns", + "table" => table.name.clone(), + )), + ) + .await, + ); } for (index, column) in columns.iter().enumerate() { let belongs = table_headers.as_ref().map_or(table_names.len() == 1, |headers| headers.get(index).is_some_and(|name| name == &table.name)); if belongs && !table.columns.contains(column) { - return render_loaded(load_page(state, &headers, submitted, Some(format!("Column '{}' is not importable for table '{}'.", column, table.name))).await); + return render_loaded( + &headers, + load_page( + state, + &headers, + submitted, + Some(tr!( + Locale::from_headers(&headers), + "import-err-column-not-importable", + "column" => column.clone(), + "table" => table.name.clone(), + )), + ) + .await, + ); } } let converted = match data_rows .iter() .enumerate() - .map(|(row_index, row)| row_for_table(table, &positions, row).map_err(|error| format!("CSV row {}: {error}", row_index + 2))) + .map(|(row_index, row)| { + row_for_table(Locale::from_headers(&headers), table, &positions, row).map_err( + |error| { + tr!( + Locale::from_headers(&headers), + "import-err-csv-row", + "row" => (row_index + 2) as i64, + "error" => error, + ) + }, + ) + }) .collect::, _>>() { Ok(rows) => rows, - Err(message) => return render_loaded(load_page(state, &headers, submitted, Some(message)).await), + Err(message) => { + return render_loaded( + &headers, + load_page(state, &headers, submitted, Some(message)).await, + ) + } }; for chunk in converted.chunks(1_000) { let request = PostTableDataBulkRequest { @@ -135,51 +225,76 @@ pub(crate) async fn import_csv( Err(_) => return Redirect::to("/login").into_response(), }).await { Ok(response) => response.into_inner(), - Err(error) => return backend_error(error.message()), + Err(error) => return backend_error(&headers, error.message().to_string()), }; inserted += response.responses.iter().filter(|row| row.inserted_id > 0).count(); } } - Html(ui::render_success(inserted, data_rows.len(), tables.len())).into_response() + Html(ui::render_success( + Locale::from_headers(&headers), + inserted, + data_rows.len(), + tables.len(), + )) + .into_response() } -fn split_headers(rows: Vec>, tables: &[String]) -> Result<(Option>, Vec, Vec>), String> { +fn split_headers( + locale: Locale, + rows: Vec>, + tables: &[String], +) -> Result<(Option>, Vec, Vec>), String> { if tables.len() > 1 { if rows.len() < 2 { - return Err("Multi-table CSV needs a table-header row and a column-header row.".to_string()); + return Err(tr!(locale, "import-err-multi-headers")); } let table_headers = rows[0].clone(); if table_headers.iter().any(|name| !tables.contains(name)) { - return Err("The first CSV row must contain only selected table names.".to_string()); + return Err(tr!(locale, "import-err-first-row")); } let columns = rows[1].clone(); if columns.len() != table_headers.len() { - return Err("Table and column header rows have different lengths.".to_string()); + return Err(tr!(locale, "import-err-header-lengths")); } - validate_width(&rows[2..], columns.len())?; + validate_width(locale, &rows[2..], columns.len())?; Ok((Some(table_headers), columns, rows[2..].to_vec())) } else { let columns = rows[0].clone(); - validate_width(&rows[1..], columns.len())?; + validate_width(locale, &rows[1..], columns.len())?; Ok((None, columns, rows[1..].to_vec())) } } -fn validate_width(rows: &[Vec], width: usize) -> Result<(), String> { +fn validate_width(locale: Locale, rows: &[Vec], width: usize) -> Result<(), String> { if rows.iter().any(|row| row.len() != width) { - Err("A CSV data row has a different number of fields than the header.".to_string()) + Err(tr!(locale, "import-err-row-width")) } else if rows.is_empty() { - Err("CSV contains headers but no data rows.".to_string()) + Err(tr!(locale, "import-err-no-data-rows")) } else { Ok(()) } } -fn row_for_table(table: &ImportTable, positions: &[(usize, String)], row: &[String]) -> Result { +fn row_for_table( + locale: Locale, + table: &ImportTable, + positions: &[(usize, String)], + row: &[String], +) -> Result { let mut data = HashMap::new(); for (index, column) in positions { - let data_type = table.types.get(column).ok_or_else(|| format!("Missing type for column '{column}'"))?; - let value = csv_value(row.get(*index).map(String::as_str).unwrap_or_default(), data_type)?; + let data_type = table.types.get(column).ok_or_else(|| { + tr!( + locale, + "import-err-missing-type", + "column" => column.clone(), + ) + })?; + let value = csv_value( + locale, + row.get(*index).map(String::as_str).unwrap_or_default(), + data_type, + )?; data.insert(column.clone(), value); } Ok(PostTableDataBulkRow { @@ -187,23 +302,37 @@ fn row_for_table(table: &ImportTable, positions: &[(usize, String)], row: &[Stri }) } -fn render_loaded(result: Result) -> Response { +fn render_loaded(headers: &HeaderMap, result: Result) -> Response { match result { Ok(page) => Html(ui::render_page(&page)).into_response(), - Err(error) => load_error(error), + Err(error) => load_error(headers, error), } } -fn load_error(error: LoadError) -> Response { +fn load_error(headers: &HeaderMap, error: LoadError) -> Response { match error { LoadError::Unauthenticated => Redirect::to("/login").into_response(), - LoadError::Forbidden => (StatusCode::FORBIDDEN, Html(ui::render_error("Insert permission is required for at least one readable table."))).into_response(), - LoadError::Backend(message) => backend_error(&message), + LoadError::Forbidden => ( + StatusCode::FORBIDDEN, + Html(ui::render_error( + Locale::from_headers(headers), + &tr!( + Locale::from_headers(headers), + "import-err-permission" + ), + )), + ) + .into_response(), + LoadError::Backend(message) => backend_error(headers, message), } } -fn backend_error(message: &str) -> Response { - (StatusCode::BAD_GATEWAY, Html(ui::render_error(message))).into_response() +fn backend_error(headers: &HeaderMap, message: String) -> Response { + ( + StatusCode::BAD_GATEWAY, + Html(ui::render_error(Locale::from_headers(headers), &message)), + ) + .into_response() } fn cross_site(headers: &HeaderMap) -> bool { @@ -220,7 +349,8 @@ mod tests { vec!["name".to_string(), "amount".to_string()], vec!["One".to_string(), "10".to_string()], ]; - let (tables, columns, data) = split_headers(rows, &["invoice".to_string()]).unwrap(); + let (tables, columns, data) = + split_headers(Locale::default(), rows, &["invoice".to_string()]).unwrap(); assert!(tables.is_none()); assert_eq!(columns, vec!["name", "amount"]); assert_eq!(data.len(), 1); @@ -234,7 +364,7 @@ mod tests { vec!["I-1".to_string(), "Acme".to_string()], ]; let targets = vec!["invoice".to_string(), "customer".to_string()]; - let (tables, columns, data) = split_headers(rows, &targets).unwrap(); + let (tables, columns, data) = split_headers(Locale::default(), rows, &targets).unwrap(); assert_eq!(tables.unwrap(), targets); assert_eq!(columns, vec!["number", "name"]); assert_eq!(data.len(), 1); diff --git a/web/src/pages/import_export/import/state.rs b/web/src/pages/import_export/import/state.rs index f8d247c4..5d22c625 100644 --- a/web/src/pages/import_export/import/state.rs +++ b/web/src/pages/import_export/import/state.rs @@ -1,3 +1,5 @@ +use crate::{i18n::Locale, tr}; + #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct ImportForm { #[serde(default)] @@ -16,10 +18,10 @@ pub(crate) struct ImportPageState { } impl ImportForm { - pub(crate) fn targets(&self) -> Result<(String, Vec), String> { + pub(crate) fn targets(&self, locale: Locale) -> Result<(String, Vec), String> { let profile = self.profile_name.trim(); if profile.is_empty() { - return Err("Select a profile.".to_string()); + return Err(tr!(locale, "import-err-select-profile")); } let tables = self .table_names @@ -29,10 +31,10 @@ impl ImportForm { .map(str::to_string) .collect::>(); if tables.is_empty() { - return Err("Enter at least one target table.".to_string()); + return Err(tr!(locale, "import-err-tables-required")); } if self.csv_data.trim().is_empty() { - return Err("Choose a CSV file or paste CSV data.".to_string()); + return Err(tr!(locale, "import-err-csv-required")); } Ok((profile.to_string(), tables)) } diff --git a/web/src/pages/import_export/import/ui.rs b/web/src/pages/import_export/import/ui.rs index f0ad7eb4..00985e84 100644 --- a/web/src/pages/import_export/import/ui.rs +++ b/web/src/pages/import_export/import/ui.rs @@ -1,5 +1,6 @@ use askama::Template; +use crate::{i18n::Locale, tr}; use crate::ui::{Alert, Nav, render}; use super::state::ImportPageState; @@ -20,13 +21,30 @@ pub(crate) fn render_page(page: &ImportPageState) -> String { } /// POST /admin/import — the #submission-status swaps. -pub(crate) fn render_error(message: &str) -> String { - render(&Alert::error("Could not import CSV", message)) +pub(crate) fn render_error(locale: Locale, message: &str) -> String { + render(&Alert::error( + locale, + &tr!(locale, "import-error-title"), + message, + )) } -pub(crate) fn render_success(inserted: usize, source_rows: usize, table_count: usize) -> String { - let message = format!( - "Inserted {inserted} record(s) from {source_rows} CSV row(s) across {table_count} table(s)." +pub(crate) fn render_success( + locale: Locale, + inserted: usize, + source_rows: usize, + table_count: usize, +) -> String { + let message = tr!( + locale, + "import-success-message", + "inserted" => inserted as i64, + "source_rows" => source_rows as i64, + "table_count" => table_count as i64, ); - render(&Alert::success("Import complete", &message)) + render(&Alert::success( + locale, + &tr!(locale, "import-success-title"), + &message, + )) } diff --git a/web/src/pages/login/logic.rs b/web/src/pages/login/logic.rs index 6ee29d4f..60e59af0 100644 --- a/web/src/pages/login/logic.rs +++ b/web/src/pages/login/logic.rs @@ -9,6 +9,7 @@ use tonic::Request; use crate::{ AppState, auth::{ChangePasswordRequest, LoginRequest}, + {i18n::Locale, tr}, services::{authenticated_request, reject_cross_site}, ui::Nav, }; @@ -46,10 +47,15 @@ pub(crate) async fn change_password( }; let mut auth = state.auth; match auth.change_password(request).await { - Ok(_) => Html(ui::render_password_success()).into_response(), + Ok(_) => { + Html(ui::render_password_success(Locale::from_headers(&headers))).into_response() + } Err(status) => ( StatusCode::UNPROCESSABLE_ENTITY, - Html(ui::render_password_error(status.message())), + Html(ui::render_password_error( + Locale::from_headers(&headers), + status.message(), + )), ) .into_response(), } @@ -57,10 +63,19 @@ pub(crate) async fn change_password( pub(crate) async fn login( State(state): State, + headers: HeaderMap, Form(input): Form, ) -> Response { if input.identifier.trim().is_empty() { - return error(StatusCode::BAD_REQUEST, "Username or email is required"); + let message = tr!( + Locale::from_headers(&headers), + "login-error-identifier-required" + ); + return error( + Locale::from_headers(&headers), + StatusCode::BAD_REQUEST, + &message, + ); } let mut client = state.auth; @@ -72,7 +87,13 @@ pub(crate) async fn login( .await { Ok(response) => response.into_inner(), - Err(status) => return error(StatusCode::UNAUTHORIZED, status.message()), + Err(status) => { + return error( + Locale::from_headers(&headers), + StatusCode::UNAUTHORIZED, + status.message(), + ) + } }; let cookie = format!( @@ -83,8 +104,9 @@ pub(crate) async fn login( ); let Ok(cookie) = HeaderValue::try_from(cookie) else { return error( + Locale::from_headers(&headers), StatusCode::BAD_GATEWAY, - "The server returned an invalid access token", + &tr!(Locale::from_headers(&headers), "login-error-invalid-token"), ); }; @@ -106,6 +128,6 @@ pub(crate) async fn login( response } -fn error(status: StatusCode, message: &str) -> Response { - (status, Html(ui::render_error(message))).into_response() +fn error(locale: Locale, status: StatusCode, message: &str) -> Response { + (status, Html(ui::render_error(locale, message))).into_response() } diff --git a/web/src/pages/login/ui.rs b/web/src/pages/login/ui.rs index c813ad88..a0867373 100644 --- a/web/src/pages/login/ui.rs +++ b/web/src/pages/login/ui.rs @@ -1,5 +1,6 @@ use askama::Template; +use crate::{i18n::Locale, tr}; use crate::ui::{Alert, Nav, render}; /// GET /login @@ -14,8 +15,12 @@ pub(crate) fn render_page(nav: Nav) -> String { } /// POST /login — the #login-status swap when the credentials are rejected. -pub(crate) fn render_error(message: &str) -> String { - render(&Alert::error("Could not sign in", message)) +pub(crate) fn render_error(locale: Locale, message: &str) -> String { + render(&Alert::error( + locale, + &tr!(locale, "login-error-title"), + message, + )) } #[derive(Template)] @@ -28,10 +33,18 @@ pub(crate) fn render_password_page(nav: Nav) -> String { render(&PasswordPage { nav }) } -pub(crate) fn render_password_error(message: &str) -> String { - render(&Alert::error("Could not change password", message)) +pub(crate) fn render_password_error(locale: Locale, message: &str) -> String { + render(&Alert::error( + locale, + &tr!(locale, "password-error-title"), + message, + )) } -pub(crate) fn render_password_success() -> String { - render(&Alert::success("Password changed", "Your new password is active.")) +pub(crate) fn render_password_success(locale: Locale) -> String { + render(&Alert::success( + locale, + &tr!(locale, "password-success-title"), + &tr!(locale, "password-changed-message"), + )) } diff --git a/web/src/pages/permissions/common/logic.rs b/web/src/pages/permissions/common/logic.rs index 0ad48d7f..a746227b 100644 --- a/web/src/pages/permissions/common/logic.rs +++ b/web/src/pages/permissions/common/logic.rs @@ -6,6 +6,7 @@ use axum::{ }; use crate::services::reject_cross_site; +use crate::{i18n::Locale, tr}; use super::{state::LoadError, ui}; @@ -23,7 +24,10 @@ where Ok(()) => success_redirect(destination), Err(message) => ( StatusCode::UNPROCESSABLE_ENTITY, - Html(ui::render_mutation_error(&message)), + Html(ui::render_mutation_error( + Locale::from_headers(headers), + &message, + )), ) .into_response(), } @@ -45,21 +49,33 @@ fn success_redirect(destination: &str) -> Response { response } -pub(crate) fn load_error(error: LoadError) -> Response { +pub(crate) fn load_error(headers: &HeaderMap, error: LoadError) -> Response { match error { LoadError::Unauthenticated => Redirect::to("/login").into_response(), LoadError::Forbidden => ( StatusCode::FORBIDDEN, Html(ui::render_error( - "This account manages neither roles nor users.", + Locale::from_headers(headers), + &tr!( + Locale::from_headers(headers), + "perm-forbidden" + ), )), ) .into_response(), LoadError::InvalidSelection(message) => { - (StatusCode::BAD_REQUEST, Html(ui::render_error(&message))).into_response() + ( + StatusCode::BAD_REQUEST, + Html(ui::render_error(Locale::from_headers(headers), &message)), + ) + .into_response() } LoadError::Backend(message) => { - (StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response() + ( + StatusCode::BAD_GATEWAY, + Html(ui::render_error(Locale::from_headers(headers), &message)), + ) + .into_response() } } } diff --git a/web/src/pages/permissions/common/state.rs b/web/src/pages/permissions/common/state.rs index e9dd47aa..e4f82d6d 100644 --- a/web/src/pages/permissions/common/state.rs +++ b/web/src/pages/permissions/common/state.rs @@ -1,3 +1,5 @@ +use crate::{i18n::Locale, tr}; + /// Why a permissions page could not be shown. pub(crate) enum LoadError { Unauthenticated, @@ -44,9 +46,9 @@ pub(crate) fn pair(object: &str, action: &str) -> String { } /// Splits the pairs a form posted, rejecting anything not shaped like one. -pub(crate) fn parse_pairs(raw: &[String]) -> Result, String> { +pub(crate) fn parse_pairs(locale: Locale, raw: &[String]) -> Result, String> { if raw.is_empty() { - return Err("Nothing to change: the form carried no permissions.".to_string()); + return Err(tr!(locale, "perm-err-nothing-to-change")); } raw.iter() .map(|value| { @@ -54,7 +56,13 @@ pub(crate) fn parse_pairs(raw: &[String]) -> Result, Strin .split_once('|') .filter(|(object, action)| !object.is_empty() && !action.is_empty()) .map(|(object, action)| (object.to_string(), action.to_string())) - .ok_or_else(|| format!("'{value}' is not an object|action pair.")) + .ok_or_else(|| { + tr!( + locale, + "perm-err-bad-pair", + "value" => value.clone(), + ) + }) }) .collect() } @@ -81,16 +89,16 @@ mod tests { fn pairs_survive_a_round_trip_and_malformed_ones_are_refused() { let encoded = vec![pair("data:acme/*", "read"), pair("journal:*", "insert")]; assert_eq!( - parse_pairs(&encoded).unwrap(), + parse_pairs(Locale::default(), &encoded).unwrap(), vec![ ("data:acme/*".to_string(), "read".to_string()), ("journal:*".to_string(), "insert".to_string()), ] ); - assert!(parse_pairs(&[]).is_err()); - assert!(parse_pairs(&["data:acme/*".to_string()]).is_err()); - assert!(parse_pairs(&["|read".to_string()]).is_err()); + assert!(parse_pairs(Locale::default(), &[]).is_err()); + assert!(parse_pairs(Locale::default(), &["data:acme/*".to_string()]).is_err()); + assert!(parse_pairs(Locale::default(), &["|read".to_string()]).is_err()); } #[test] diff --git a/web/src/pages/permissions/common/ui.rs b/web/src/pages/permissions/common/ui.rs index b712191c..12fe59aa 100644 --- a/web/src/pages/permissions/common/ui.rs +++ b/web/src/pages/permissions/common/ui.rs @@ -1,13 +1,18 @@ +use crate::{i18n::Locale, tr}; use crate::ui::{Alert, ErrorPage, Nav, render}; -pub(crate) fn render_error(message: &str) -> String { +pub(crate) fn render_error(locale: Locale, message: &str) -> String { render(&ErrorPage { nav: Nav::default(), - heading: "Permissions unavailable", + heading: &tr!(locale, "perm-unavailable-title"), message, }) } -pub(crate) fn render_mutation_error(message: &str) -> String { - render(&Alert::error("Could not update permissions", message)) +pub(crate) fn render_mutation_error(locale: Locale, message: &str) -> String { + render(&Alert::error( + locale, + &tr!(locale, "perm-update-error"), + message, + )) } diff --git a/web/src/pages/permissions/grants/loader.rs b/web/src/pages/permissions/grants/loader.rs index d1f10860..7a06e4cd 100644 --- a/web/src/pages/permissions/grants/loader.rs +++ b/web/src/pages/permissions/grants/loader.rs @@ -7,6 +7,7 @@ use axum::http::HeaderMap; use crate::{ AppState, auth::{GrantableObject, ListGrantableObjectsRequest, ListRolePermissionsRequest, Permission}, + {i18n::Locale, tr}, pages::permissions::common::{ loader, state::{LoadError, Tabs}, @@ -37,12 +38,15 @@ pub(crate) async fn load_page( match editable.iter().find(|role| role.name == requested) { Some(role) => Some(role.clone()), None => { - return Err(LoadError::InvalidSelection(format!( - "'{requested}' is not a role you may edit." + return Err(LoadError::InvalidSelection(tr!( + Locale::from_headers(headers), + "perm-err-not-editable-role", + "role" => requested.to_string(), ))); } } }; + let locale = Locale::from_headers(headers); let (permissions, objects) = match &selected { Some(role) => { @@ -100,6 +104,7 @@ pub(crate) async fn load_page( .as_ref() .is_some_and(|role| role.kind == "structural"), groups: group_objects( + locale, &objects, &permissions.permissions, &permissions.effective_permissions, @@ -112,6 +117,7 @@ pub(crate) async fn load_page( /// order it sent: the wildcards that cover everything first, then each profile /// with its own wildcards ahead of its tables. fn group_objects( + locale: Locale, objects: &[GrantableObject], direct: &[Permission], effective: &[Permission], @@ -121,7 +127,7 @@ fn group_objects( for object in objects { let global = object.profile.is_empty(); let title = if global { - "Everything".to_string() + tr!(locale, "grants-everything-title") } else { object.profile.clone() }; @@ -138,7 +144,7 @@ fn group_objects( } }; - let (label, note) = describe(object); + let (label, note) = describe(locale, object); groups[index].rows.push(ObjectRow { wildcard: object.table.is_empty(), label, @@ -165,35 +171,35 @@ fn group_objects( /// What each object covers, said in the terms the person granting it thinks in /// rather than in object strings. -fn describe(object: &GrantableObject) -> (String, String) { +fn describe(locale: Locale, object: &GrantableObject) -> (String, String) { match object.kind.as_str() { "global_data" => ( - "All tables".to_string(), - "Every table in every profile, including ones added later.".to_string(), + tr!(locale, "grants-label-all-tables"), + tr!(locale, "grants-note-all-tables"), ), "global_journal" => ( - "All journals".to_string(), - "Every profile's accounting journal.".to_string(), + tr!(locale, "grants-label-all-journals"), + tr!(locale, "grants-note-all-journals"), ), "global_ecb" => ( - "All exchange rates".to_string(), - "ECB rates are imported by the server, so they can only be read.".to_string(), + tr!(locale, "grants-label-all-rates"), + tr!(locale, "grants-note-all-rates"), ), "profile" => ( - "All tables".to_string(), - "Every table in this profile, including ones added later.".to_string(), + tr!(locale, "grants-label-all-tables"), + tr!(locale, "grants-note-profile-tables"), ), "journal" => ( - "Journal".to_string(), - "This profile's accounting journal.".to_string(), + tr!(locale, "grants-label-journal"), + tr!(locale, "grants-note-journal"), ), "ecb" => ( - "Exchange rates".to_string(), - "Imported by the server, so read is the only grant.".to_string(), + tr!(locale, "grants-label-rates"), + tr!(locale, "grants-note-rates"), ), _ => ( object.table.clone(), - "This table and its whole template family.".to_string(), + tr!(locale, "grants-note-table"), ), } } diff --git a/web/src/pages/permissions/grants/logic.rs b/web/src/pages/permissions/grants/logic.rs index bdd040b0..63986a3e 100644 --- a/web/src/pages/permissions/grants/logic.rs +++ b/web/src/pages/permissions/grants/logic.rs @@ -8,6 +8,7 @@ use axum_extra::extract::Form; use crate::{ AppState, auth::{GrantPermissionRequest, RevokePermissionRequest}, + {i18n::Locale, tr}, pages::permissions::common::{ logic::{load_error, mutate}, state::{parse_pairs, return_path}, @@ -28,7 +29,7 @@ pub(crate) async fn page( ) -> Response { match loader::load_page(state, &headers, selection).await { Ok(page) => Html(ui::render_page(&page)).into_response(), - Err(error) => load_error(error), + Err(error) => load_error(&headers, error), } } @@ -50,12 +51,19 @@ pub(crate) async fn apply( format!("/permissions/grants?role={role}&updated=true"), ); let request_headers = headers.clone(); + let locale = Locale::from_headers(&headers); mutate(&headers, &destination, async move { - let pairs = parse_pairs(&form.pair)?; + let pairs = parse_pairs(locale, &form.pair)?; let granting = match form.mode.as_str() { "grant" => true, "revoke" => false, - other => return Err(format!("'{other}' is neither grant nor revoke.")), + other => { + return Err(tr!( + locale, + "perm-err-neither-grant-nor-revoke", + "value" => other.to_string(), + )) + } }; let mut auth = state.auth; @@ -70,11 +78,17 @@ pub(crate) async fn apply( action: action.clone(), }, ) - .map_err(|_| "Missing session".to_string())?, + .map_err(|_| tr!(locale, "perm-err-missing-session"))?, ) .await .map_err(|error| { - format!("Granting {action} on {object} failed: {}", error.message()) + tr!( + locale, + "perm-err-grant-failed", + "action" => action.clone(), + "object" => object.clone(), + "error" => error.message().to_string(), + ) })?; } else if let Err(error) = auth .revoke_permission( @@ -86,14 +100,17 @@ pub(crate) async fn apply( action: action.clone(), }, ) - .map_err(|_| "Missing session".to_string())?, + .map_err(|_| tr!(locale, "perm-err-missing-session"))?, ) .await && error.code() != tonic::Code::NotFound { - return Err(format!( - "Revoking {action} on {object} failed: {}", - error.message() + return Err(tr!( + locale, + "perm-err-revoke-failed", + "action" => action, + "object" => object, + "error" => error.message().to_string(), )); } } diff --git a/web/src/pages/permissions/mod.rs b/web/src/pages/permissions/mod.rs index 40200047..c7e8f1ee 100644 --- a/web/src/pages/permissions/mod.rs +++ b/web/src/pages/permissions/mod.rs @@ -38,6 +38,6 @@ async fn entry(State(state): State, headers: HeaderMap) -> Response { match common::loader::access(&mut auth, &headers).await { Ok(access) if access.can_manage_roles => Redirect::to("/permissions/roles").into_response(), Ok(_) => Redirect::to("/permissions/users").into_response(), - Err(error) => common::logic::load_error(error), + Err(error) => common::logic::load_error(&headers, error), } } diff --git a/web/src/pages/permissions/roles/logic.rs b/web/src/pages/permissions/roles/logic.rs index ce81bc97..69c0340d 100644 --- a/web/src/pages/permissions/roles/logic.rs +++ b/web/src/pages/permissions/roles/logic.rs @@ -8,6 +8,7 @@ use axum_extra::extract::Form; use crate::{ AppState, auth::{AddRoleRequest, GrantPermissionRequest, RemoveRoleRequest}, + {i18n::Locale, tr}, pages::permissions::common::logic::{load_error, mutate}, services::authenticated_request, }; @@ -25,7 +26,7 @@ pub(crate) async fn page( ) -> Response { match loader::load_page(state, &headers, selection).await { Ok(page) => Html(ui::render_page(&page)).into_response(), - Err(error) => load_error(error), + Err(error) => load_error(&headers, error), } } @@ -41,8 +42,9 @@ pub(crate) async fn create( let role = form.name.trim().to_string(); let destination = format!("/permissions/grants?role={role}&updated=true"); let request_headers = headers.clone(); + let locale = Locale::from_headers(&headers); mutate(&headers, &destination, async move { - let grants = starter_grants(form.access.trim())?; + let grants = starter_grants(locale, form.access.trim())?; let mut auth = state.auth; auth.add_role( authenticated_request( @@ -52,7 +54,7 @@ pub(crate) async fn create( parent: form.parent.trim().to_string(), }, ) - .map_err(|_| "Missing session".to_string())?, + .map_err(|_| tr!(locale, "perm-err-missing-session"))?, ) .await .map_err(|error| error.message().to_string())?; @@ -67,13 +69,17 @@ pub(crate) async fn create( action: action.to_string(), }, ) - .map_err(|_| "Missing session".to_string())?, + .map_err(|_| tr!(locale, "perm-err-missing-session"))?, ) .await .map_err(|error| { - format!( - "Role '{role}' was created, but granting {action} on {object} failed: {}", - error.message() + tr!( + locale, + "perm-role-created-grant-failed", + "role" => role.clone(), + "action" => action.to_string(), + "object" => object.to_string(), + "error" => error.message().to_string(), ) })?; } @@ -88,11 +94,12 @@ pub(crate) async fn remove( Form(form): Form, ) -> Response { let request_headers = headers.clone(); + let locale = Locale::from_headers(&headers); mutate(&headers, "/permissions/roles?updated=true", async move { let mut auth = state.auth; auth.remove_role( authenticated_request(&request_headers, RemoveRoleRequest { name: form.role }) - .map_err(|_| "Missing session".to_string())?, + .map_err(|_| tr!(locale, "perm-err-missing-session"))?, ) .await .map_err(|error| error.message().to_string())?; diff --git a/web/src/pages/permissions/roles/state.rs b/web/src/pages/permissions/roles/state.rs index cb16b884..63643787 100644 --- a/web/src/pages/permissions/roles/state.rs +++ b/web/src/pages/permissions/roles/state.rs @@ -1,4 +1,8 @@ -use crate::{pages::permissions::common::state::Tabs, ui::Nav}; +use crate::{ + {i18n::Locale, tr}, + pages::permissions::common::state::Tabs, + ui::Nav, +}; #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct Selection { @@ -50,15 +54,15 @@ impl RoleRow { /// Why the delete button is missing, so an undeletable role says so instead /// of showing nothing. - pub(crate) fn keeps_reason(&self) -> &'static str { + pub(crate) fn keeps_reason(&self, locale: &Locale) -> String { if !self.editable { - "outranks you" + tr!(*locale, "perm-role-reason-outranks-you") } else if self.built_in { - "built in" + tr!(*locale, "perm-role-reason-built-in") } else if self.users.is_some_and(|count| count > 0) { - "still assigned" + tr!(*locale, "perm-role-reason-still-assigned") } else { - "" + String::new() } } } @@ -69,7 +73,10 @@ impl RoleRow { /// tables added later, which is what "everything" has to mean for a role /// created before the data exists. ECB rates are written by the server, so they /// are readable and nothing more. -pub(crate) fn starter_grants(access: &str) -> Result, String> { +pub(crate) fn starter_grants( + locale: Locale, + access: &str, +) -> Result, String> { match access { "" | "none" => Ok(Vec::new()), "read" => Ok(vec![ @@ -86,7 +93,11 @@ pub(crate) fn starter_grants(access: &str) -> Result Err(format!("'{other}' is not a starter access level.")), + other => Err(tr!( + locale, + "perm-err-bad-starter-access", + "value" => other.to_string(), + )), } } @@ -96,11 +107,12 @@ mod tests { #[test] fn starter_access_never_hands_out_a_write_on_ecb_rates() { - assert!(starter_grants("none").unwrap().is_empty()); - assert!(starter_grants("nonsense").is_err()); + let locale = Locale::default(); + assert!(starter_grants(locale, "none").unwrap().is_empty()); + assert!(starter_grants(locale, "nonsense").is_err()); for level in ["read", "full"] { - let grants = starter_grants(level).unwrap(); + let grants = starter_grants(locale, level).unwrap(); assert!( grants .iter() @@ -109,8 +121,8 @@ mod tests { ); } - assert_eq!(starter_grants("read").unwrap().len(), 3); - assert_eq!(starter_grants("full").unwrap().len(), 9); + assert_eq!(starter_grants(locale, "read").unwrap().len(), 3); + assert_eq!(starter_grants(locale, "full").unwrap().len(), 9); } #[test] @@ -128,6 +140,9 @@ mod tests { assert!(!row(false, Some(2), true).removable()); assert!(!row(true, Some(0), true).removable()); assert!(!row(false, Some(0), false).removable()); - assert_eq!(row(false, Some(2), true).keeps_reason(), "still assigned"); + assert_eq!( + row(false, Some(2), true).keeps_reason(&Locale::default()), + "still assigned" + ); } } diff --git a/web/src/pages/permissions/users/logic.rs b/web/src/pages/permissions/users/logic.rs index 2d07e4be..19d5893d 100644 --- a/web/src/pages/permissions/users/logic.rs +++ b/web/src/pages/permissions/users/logic.rs @@ -8,6 +8,7 @@ use axum_extra::extract::Form; use crate::{ AppState, auth::{AssignUserRoleRequest, ResetUserPasswordRequest}, + {i18n::Locale, tr}, pages::permissions::common::logic::{load_error, mutate}, services::authenticated_request, }; @@ -27,7 +28,7 @@ pub(crate) async fn page( ) -> Response { match loader::load_page(state, &headers, selection).await { Ok(page) => Html(ui::render_page(&page)).into_response(), - Err(error) => load_error(error), + Err(error) => load_error(&headers, error), } } @@ -37,6 +38,7 @@ pub(crate) async fn assign_role( Form(form): Form, ) -> Response { let request_headers = headers.clone(); + let locale = Locale::from_headers(&headers); mutate(&headers, DESTINATION, async move { let mut auth = state.auth; auth.assign_user_role( @@ -47,7 +49,7 @@ pub(crate) async fn assign_role( role: form.role, }, ) - .map_err(|_| "Missing session".to_string())?, + .map_err(|_| tr!(locale, "perm-err-missing-session"))?, ) .await .map_err(|error| error.message().to_string())?; @@ -62,6 +64,7 @@ pub(crate) async fn reset_password( Form(form): Form, ) -> Response { let request_headers = headers.clone(); + let locale = Locale::from_headers(&headers); mutate(&headers, DESTINATION, async move { let mut auth = state.auth; auth.reset_user_password( @@ -73,7 +76,7 @@ pub(crate) async fn reset_password( new_password_confirmation: form.new_password_confirmation, }, ) - .map_err(|_| "Missing session".to_string())?, + .map_err(|_| tr!(locale, "perm-err-missing-session"))?, ) .await .map_err(|error| error.message().to_string())?; diff --git a/web/src/pages/register/logic.rs b/web/src/pages/register/logic.rs index a1a8ab38..93ef6bce 100644 --- a/web/src/pages/register/logic.rs +++ b/web/src/pages/register/logic.rs @@ -6,7 +6,7 @@ use axum::{ }; use tonic::Request; -use crate::{AppState, auth::RegisterRequest, ui::Nav}; +use crate::{AppState, auth::RegisterRequest, i18n::Locale, ui::Nav}; use super::{state::RegisterInput, ui}; @@ -16,6 +16,7 @@ pub(crate) async fn register_page(headers: HeaderMap) -> Html { pub(crate) async fn register( State(state): State, + headers: HeaderMap, Form(input): Form, ) -> Response { // Same shaping as the client's `RegisterFormState::try_register`: trim @@ -40,7 +41,10 @@ pub(crate) async fn register( Err(status) => { return ( StatusCode::BAD_REQUEST, - Html(ui::render_error(status.message())), + Html(ui::render_error( + Locale::from_headers(&headers), + status.message(), + )), ) .into_response(); } @@ -48,5 +52,11 @@ pub(crate) async fn register( // The client reports the registered account back to the user and stays on // the form — registering does not sign anyone in. - Html(ui::render_success(®istered, &timezone, &phone_country)).into_response() + Html(ui::render_success( + Locale::from_headers(&headers), + ®istered, + &timezone, + &phone_country, + )) + .into_response() } diff --git a/web/src/pages/register/ui.rs b/web/src/pages/register/ui.rs index fe475ba7..aa7c4752 100644 --- a/web/src/pages/register/ui.rs +++ b/web/src/pages/register/ui.rs @@ -2,6 +2,7 @@ use askama::Template; use crate::{ auth::AuthResponse, + {i18n::Locale, tr}, ui::{Alert, Nav, render}, }; @@ -27,26 +28,33 @@ pub(crate) fn render_page(nav: Nav) -> String { /// POST /register — the #register-status swap when the account was created. /// The lines are the client's success dialog, field for field. pub(crate) fn render_success( + locale: Locale, registered: &AuthResponse, timezone: &str, phone_country: &str, ) -> String { - let message = format!( - "User ID: {}\nUsername: {}\nEmail: {}\nRole: {}\nTimezone: {}\nPhone country: {}", - registered.id, - registered.username, - registered.email, - registered.role, - timezone, - phone_country, + let message = tr!( + locale, + "register-success-message", + "id" => registered.id.to_string(), + "username" => registered.username.clone(), + "email" => registered.email.clone(), + "role" => registered.role.clone(), + "timezone" => timezone.to_string(), + "phone_country" => phone_country.to_string(), ); render(&Alert::success( - "Registration successful. You can sign in now.", + locale, + &tr!(locale, "register-success-title"), &message, )) } /// POST /register — the #register-status swap when the backend refused. -pub(crate) fn render_error(message: &str) -> String { - render(&Alert::error("Could not register", message)) +pub(crate) fn render_error(locale: Locale, message: &str) -> String { + render(&Alert::error( + locale, + &tr!(locale, "register-error-title"), + message, + )) } diff --git a/web/src/schema/mod.rs b/web/src/schema/mod.rs index 737e9cf9..f0491904 100644 --- a/web/src/schema/mod.rs +++ b/web/src/schema/mod.rs @@ -295,33 +295,48 @@ impl ColumnCatalog { } /// Whether the server would accept this as a column's declared type. - pub(crate) fn validate_field_type(&self, field_type: &str) -> Option { + pub(crate) fn validate_field_type( + &self, + locale: crate::i18n::Locale, + field_type: &str, + ) -> Option { let field_type = field_type.to_lowercase(); if let Some((precision, scale)) = decimal_arguments(&field_type) { if !self.is_parameterised("decimal") { - return Some("`decimal` is not a valid field type.".to_string()); + return Some(crate::tr!( + locale, + "schema-err-decimal-not-valid" + )); } - return validate_decimal_arguments(precision, scale).err(); + return validate_decimal_arguments(locale, precision, scale).err(); } if let Some(target) = link_argument(&field_type) { if !self.is_link("link") { - return Some("`link` is not a valid field type.".to_string()); + return Some(crate::tr!(locale, "schema-err-link-not-valid")); } - return validate_identifier(target, "Linked table", true); + return validate_identifier(locale, target, "label-linked-table", true); } match self.find(&field_type) { // A parameterised type spelled bare is missing its arguments. - Some(column_type) if column_type.parameterised => Some(format!( - "`{field_type}` needs both a precision and a scale." + Some(column_type) if column_type.parameterised => Some(crate::tr!( + locale, + "schema-err-decimal-args-needed", + "type" => field_type.clone(), )), Some(column_type) if column_type.link => { - Some("`link` needs a referenced table.".to_string()) + Some(crate::tr!(locale, "schema-err-link-target-needed")) } Some(column_type) if column_type.declarable => None, - Some(_) => Some(format!( - "`{field_type}` is a column type the backend generates itself." + Some(_) => Some(crate::tr!( + locale, + "schema-err-generated-type", + "type" => field_type.clone(), + )), + None => Some(crate::tr!( + locale, + "schema-err-invalid-type", + "type" => field_type.clone(), )), - None => Some(format!("`{field_type}` is not a valid field type.")), } } } @@ -371,6 +386,15 @@ impl MoneyMode { } } + /// The display name shown in badges and the preview, in the request's + /// language. `label` stays the wire value; this is what a reader sees. + pub(crate) fn display_label(self, locale: &crate::i18n::Locale) -> String { + match self { + Self::Exact => crate::tr!(*locale, "td-money-exact"), + Self::Rounded => crate::tr!(*locale, "td-money-half-up"), + } + } + pub(crate) fn from_input(value: &str) -> Self { if value.trim().eq_ignore_ascii_case("half-up") { Self::Rounded @@ -410,16 +434,24 @@ impl ColumnDefinition { /// /// The currency is shown whenever there is one, which is the same thing as /// asking the catalog: it is only ever stored for a type that requires it. - pub(crate) fn option_label(&self) -> String { + pub(crate) fn option_label(&self, locale: &crate::i18n::Locale) -> String { let mut options = Vec::new(); if self.required { - options.push("required".to_string()); + options.push(crate::tr!(*locale, "column-flag-required")); } if self.is_indexed() { - options.push("indexed".to_string()); + options.push(crate::tr!(*locale, "builder-option-indexed")); } if !self.currency.is_empty() { - options.push(format!("{}, {}", self.currency, self.money_mode.label())); + let mode = match self.money_mode { + MoneyMode::Exact => "exact", + MoneyMode::Rounded => "half-up", + }; + options.push(format!( + "{}, {}", + self.currency, + crate::tr!(*locale, &format!("td-money-{mode}")), + )); } options.join(", ") } @@ -593,7 +625,7 @@ impl ColumnDraft { /// and the `decimal` arguments to their canonical form. `None` while the /// choice is still incomplete, `Err` when the follow-up fields are filled /// in but wrong. - fn canonical_type_input(&self) -> Result, String> { + fn canonical_type_input(&self, locale: crate::i18n::Locale) -> Result, String> { let column_type = self.type_input.trim().to_ascii_lowercase(); if column_type.is_empty() { return Ok(None); @@ -604,7 +636,7 @@ impl ColumnDraft { if precision.is_empty() && scale.is_empty() { return Ok(None); } - validate_decimal_arguments(precision, scale)?; + validate_decimal_arguments(locale, precision, scale)?; return Ok(Some(format!("{column_type}({precision},{scale})"))); } if self.catalog.is_link(&column_type) { @@ -633,15 +665,22 @@ impl ColumnDraft { } /// Appends the pending column, then clears the input panel. - pub(crate) fn add_from_inputs(&mut self) -> Result { - let Some(column_type) = self.canonical_type_input()? else { - return Err("Both a column name and a column type are required.".to_string()); + pub(crate) fn add_from_inputs( + &mut self, + locale: crate::i18n::Locale, + ) -> Result { + let Some(column_type) = self.canonical_type_input(locale)? else { + return Err(crate::tr!( + locale, + "schema-err-both-name-type" + )); }; if !self.creating_table && self.catalog.is_creation_only(&column_type) { - return Err(format!( - "A {} column can only be chosen while the table is being created.", - column_type.to_uppercase() + return Err(crate::tr!( + locale, + "schema-err-creation-only", + "type" => column_type.to_uppercase(), )); } @@ -655,49 +694,64 @@ impl ColumnDraft { }; if column_name.is_empty() { - return Err("Both a column name and a column type are required.".to_string()); + return Err(crate::tr!( + locale, + "schema-err-both-name-type" + )); } - if let Some(error) = validate_identifier(&column_name, "Column name", true) { + if let Some(error) = + validate_identifier(locale, &column_name, "label-column-name", true) + { return Err(error); } - if let Some(error) = self.catalog.validate_field_type(&column_type) { + if let Some(error) = self.catalog.validate_field_type(locale, &column_type) { return Err(error); } - if let Some(error) = self.profile_only_type_error(&column_type) { + if let Some(error) = self.profile_only_type_error(locale, &column_type) { return Err(error); } - if let Some(error) = self.link_target_error(&column_name, &column_type) { + if let Some(error) = self.link_target_error(locale, &column_name, &column_type) { return Err(error); } // Against the names the table will really hold, not just the declared // ones: a definition row's companions are columns too, and the server // refuses a table where a declared name collides with one of them. if self.claimed_names().iter().any(|name| name == &column_name) { - return Err(format!("A column named `{column_name}` already exists.")); + return Err(crate::tr!( + locale, + "schema-err-column-exists", + "name" => column_name.clone(), + )); } for generated in self.catalog.generated_columns(&column_type) { let generated = self.companion_name(&column_name, &generated.name); if self.claimed_names().iter().any(|name| name == &generated) { - return Err(format!( - "`{column_type}` generates a column named `{generated}`, and the table already has one." + return Err(crate::tr!( + locale, + "schema-err-generated-exists", + "type" => column_type.clone(), + "generated" => generated, )); } } let quantity_ledger = self.quantity_ledger_input.trim().eq_ignore_ascii_case("yes"); if quantity_ledger && !self.catalog.allows_quantity_ledger(&column_type) { - return Err(format!( - "Quantity-ledger columns must use {}", - self.catalog.quantity_ledger_types() + return Err(crate::tr!( + locale, + "schema-err-ql-types", + "types" => self.catalog.quantity_ledger_types(), )); } - if let Some(error) = self.global_quantity_ledger_error(quantity_ledger, &column_name) { + if let Some(error) = + self.global_quantity_ledger_error(locale, quantity_ledger, &column_name) + { return Err(error); } let has_currency = self.catalog.requires_currency(&column_type); let currency = if has_currency { - normalize_currency_input(&self.currency_input)? + normalize_currency_input(locale, &self.currency_input)? } else { String::new() }; @@ -721,7 +775,11 @@ impl ColumnDraft { }); self.clear_inputs(); - Ok(format!("Column `{column_name}` added.")) + Ok(crate::tr!( + locale, + "schema-status-column-added", + "name" => column_name, + )) } fn clear_inputs(&mut self) { @@ -782,19 +840,31 @@ impl ColumnDraft { /// The books a definition row posts to belong to one profile, and a shared /// table belongs to all of them. - fn profile_only_type_error(&self, column_type: &str) -> Option { + fn profile_only_type_error( + &self, + locale: crate::i18n::Locale, + column_type: &str, + ) -> Option { (self.global && PROFILE_ONLY_TYPES.contains(&column_type)).then(|| { - format!( - "A shared table cannot use {}: it posts to one profile's books, and a shared table belongs to every profile.", - column_type.to_uppercase() + crate::tr!( + locale, + "schema-err-profile-only-type", + "type" => column_type.to_uppercase(), ) }) } - fn global_quantity_ledger_error(&self, quantity_ledger: bool, name: &str) -> Option { + fn global_quantity_ledger_error( + &self, + locale: crate::i18n::Locale, + quantity_ledger: bool, + name: &str, + ) -> Option { (self.global && quantity_ledger).then(|| { - format!( - "Column `{name}`: a shared table cannot keep a quantity ledger, which belongs to one profile." + crate::tr!( + locale, + "schema-err-shared-ql", + "name" => name.to_string(), ) }) } @@ -806,16 +876,22 @@ impl ColumnDraft { /// `table_name` is empty on the append screen's panel, where the page /// filters its own table out of the picker and there is nothing to compare /// against here. - fn link_target_error(&self, column_name: &str, column_type: &str) -> Option { + fn link_target_error( + &self, + locale: crate::i18n::Locale, + column_name: &str, + column_type: &str, + ) -> Option { let target = link_target(column_type)?; if target == ACCOUNTS_TABLE { - return Some( - "The account relationship is built into ACCOUNTING and cannot be declared as a link." - .to_string(), - ); + return Some(crate::tr!(locale, "schema-err-account-link")); } (!self.table_name.is_empty() && target == self.table_name).then(|| { - format!("Link `{column_name}` cannot point at the table being created.") + crate::tr!( + locale, + "schema-err-link-self", + "column" => column_name.to_string(), + ) }) } @@ -823,9 +899,13 @@ impl ColumnDraft { /// Removes one column. The caller is what knows whether anything else /// referenced it — `add_table` drops it from the display columns too. - pub(crate) fn remove(&mut self, index: usize) -> Result { + pub(crate) fn remove( + &mut self, + locale: crate::i18n::Locale, + index: usize, + ) -> Result { if index >= self.added.len() { - return Err("That column no longer exists.".to_string()); + return Err(crate::tr!(locale, "schema-err-column-gone")); } Ok(self.added.remove(index)) } @@ -839,17 +919,30 @@ impl ColumnDraft { /// /// A move off either end is not an error: the buttons for it are not /// rendered, and a crafted post asking for one leaves the order alone. - pub(crate) fn move_column(&mut self, index: usize, offset: isize) -> Option { + pub(crate) fn move_column( + &mut self, + locale: crate::i18n::Locale, + index: usize, + offset: isize, + ) -> Option { let target = index.checked_add_signed(offset)?; if index >= self.added.len() || target >= self.added.len() { return None; } self.added.swap(index, target); - Some(format!( - "Column `{}` moved {}.", - self.added[target].name, - if offset < 0 { "up" } else { "down" } - )) + Some(if offset < 0 { + crate::tr!( + locale, + "schema-status-column-moved-up", + "name" => self.added[target].name.clone(), + ) + } else { + crate::tr!( + locale, + "schema-status-column-moved-down", + "name" => self.added[target].name.clone(), + ) + }) } /// What an already-added column expands into — empty unless it is a @@ -925,61 +1018,80 @@ impl ColumnDraft { /// [`Self::add_from_inputs`] already applies these, but a draft rebuilt /// from a posted form has not been through that path, so this is what a /// tampered-with or truncated post is held to. - pub(crate) fn validate(&self) -> Result<(), String> { + pub(crate) fn validate(&self, locale: crate::i18n::Locale) -> Result<(), String> { let claimed = self.claimed_names(); for column in &self.added { - if let Some(error) = validate_identifier(&column.name, "Column name", true) { + if let Some(error) = + validate_identifier(locale, &column.name, "label-column-name", true) + { return Err(error); } - if let Some(error) = self.catalog.validate_field_type(&column.data_type) { - return Err(format!("Column `{}`: {error}", column.name)); + if let Some(error) = self.catalog.validate_field_type(locale, &column.data_type) { + return Err(crate::tr!( + locale, + "schema-err-column-prefix", + "name" => column.name.clone(), + "error" => error, + )); } - if let Some(error) = self.profile_only_type_error(&column.data_type) { - return Err(error); - } - if let Some(error) = self.link_target_error(&column.name, &column.data_type) { + if let Some(error) = self.profile_only_type_error(locale, &column.data_type) { return Err(error); } if let Some(error) = - self.global_quantity_ledger_error(column.quantity_ledger, &column.name) + self.link_target_error(locale, &column.name, &column.data_type) + { + return Err(error); + } + if let Some(error) = + self.global_quantity_ledger_error(locale, column.quantity_ledger, &column.name) { return Err(error); } if column.indexed && column.is_link() { - return Err(format!( - "Link `{}` is indexed automatically and cannot be indexed again.", - column.name + return Err(crate::tr!( + locale, + "schema-err-link-indexed", + "name" => column.name.clone(), )); } if column.indexed && self.catalog.is_compound(&column.data_type) { - return Err(format!( - "`{}` is a definition row, not a column, so it cannot be indexed.", - column.name + return Err(crate::tr!( + locale, + "schema-err-definition-indexed", + "name" => column.name.clone(), )); } if !self.creating_table && self.catalog.is_creation_only(&column.data_type) { - return Err(format!( - "A {} column can only be chosen while the table is being created.", - column.data_type.to_uppercase() + return Err(crate::tr!( + locale, + "schema-err-creation-only", + "type" => column.data_type.to_uppercase(), )); } if column.quantity_ledger && !self.catalog.allows_quantity_ledger(&column.data_type) { - return Err(format!( - "Column `{}`: quantity-ledger columns must use {}", - column.name, - self.catalog.quantity_ledger_types() + return Err(crate::tr!( + locale, + "schema-err-column-ql-types", + "name" => column.name.clone(), + "types" => self.catalog.quantity_ledger_types(), )); } // The same rule the server enforces: required for a money column, // forbidden for every other type. if self.catalog.requires_currency(&column.data_type) { - if let Err(error) = normalize_currency_input(&column.currency) { - return Err(format!("Column `{}`: {error}", column.name)); + if let Err(error) = normalize_currency_input(locale, &column.currency) { + return Err(crate::tr!( + locale, + "schema-err-column-prefix", + "name" => column.name.clone(), + "error" => error, + )); } } else if !column.currency.trim().is_empty() { - return Err(format!( - "Column `{}`: a currency belongs only to a column type that requires one.", - column.name + return Err(crate::tr!( + locale, + "schema-err-currency-only", + "name" => column.name.clone(), )); } } @@ -987,8 +1099,10 @@ impl ColumnDraft { // definition rows generate — which is the set the server checks. for (position, name) in claimed.iter().enumerate() { if claimed[position + 1..].contains(name) { - return Err(format!( - "`{name}` names more than one of this table's columns." + return Err(crate::tr!( + locale, + "schema-err-name-twice", + "name" => name.clone(), )); } } @@ -996,43 +1110,68 @@ impl ColumnDraft { } } -pub(crate) fn normalize_currency_input(value: &str) -> Result { +pub(crate) fn normalize_currency_input( + locale: crate::i18n::Locale, + value: &str, +) -> Result { let currency = value.trim().to_ascii_uppercase(); if rusty_money::iso::find(¤cy).is_none() { - return Err("Currency must be a three-letter ISO-4217 code".to_string()); + return Err(crate::tr!(locale, "schema-err-currency-code")); } Ok(currency) } /// PostgreSQL identifier rules, plus the names this schema reserves. pub(crate) fn validate_identifier( + locale: crate::i18n::Locale, value: &str, - label: &str, + label_key: &str, reject_table_reserved: bool, ) -> Option { + let label = crate::tr!(locale, label_key); if value.is_empty() { - return Some(format!("{label} cannot be empty.")); + return Some(crate::tr!( + locale, + "error-identifier-empty", + "label" => label, + )); } if value != value.trim() { - return Some(format!("{label} cannot start or end with a space.")); + return Some(crate::tr!( + locale, + "error-identifier-whitespace", + "label" => label, + )); } if value.starts_with('_') { - return Some(format!("{label} cannot start with an underscore.")); + return Some(crate::tr!( + locale, + "error-identifier-underscore", + "label" => label, + )); } if value.chars().next().is_some_and(|c| c.is_ascii_digit()) { - return Some(format!("{label} cannot start with a number.")); + return Some(crate::tr!( + locale, + "error-identifier-number", + "label" => label, + )); } if value.len() > MAX_IDENTIFIER_LENGTH { - return Some(format!( - "{label} cannot be longer than {MAX_IDENTIFIER_LENGTH} characters." + return Some(crate::tr!( + locale, + "error-identifier-too-long", + "label" => label, )); } if value .chars() .any(|c| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_') { - return Some(format!( - "{label} may only use lowercase letters, digits and underscores." + return Some(crate::tr!( + locale, + "error-identifier-charset", + "label" => label, )); } // Only the system columns are reserved. The `_id` suffix is free: no @@ -1041,12 +1180,16 @@ pub(crate) fn validate_identifier( if reject_table_reserved && matches!(value, "id" | "deleted" | "created_at" | "row_revision") { - return Some(format!("{label} uses a reserved name.")); + return Some(crate::tr!( + locale, + "error-identifier-reserved", + "label" => label, + )); } if !reject_table_reserved && (value == "public" || value == "information_schema" || value.starts_with("pg_")) { - return Some("That profile name is reserved by PostgreSQL.".to_string()); + return Some(crate::tr!(locale, "schema-err-profile-reserved")); } None } @@ -1087,19 +1230,21 @@ pub(crate) const RESERVED_TABLE_NAMES: [&str; 5] = [ ]; /// The identifier rules, plus the two a table name alone is held to. -pub(crate) fn validate_table_name(value: &str) -> Option { - if let Some(error) = validate_identifier(value, "Table name", true) { +pub(crate) fn validate_table_name( + locale: crate::i18n::Locale, + value: &str, +) -> Option { + if let Some(error) = validate_identifier(locale, value, "label-table-name", true) { return Some(error); } if value.len() > MAX_TABLE_NAME_LENGTH { - return Some(format!( - "Table name cannot be longer than {MAX_TABLE_NAME_LENGTH} characters, because the \ - indexes on its columns are named after it." - )); + return Some(crate::tr!(locale, "schema-err-table-name-too-long")); } if RESERVED_TABLE_NAMES.contains(&value) { - return Some(format!( - "`{value}` is the name of a table every profile is given, so it cannot be reused." + return Some(crate::tr!( + locale, + "schema-err-table-name-reserved", + "name" => value.to_string(), )); } None @@ -1107,34 +1252,63 @@ pub(crate) fn validate_table_name(value: &str) -> Option { /// The precision and scale rules the server applies to `decimal(p,s)`: /// whole numbers, no sign, no leading zeros, `1 <= p` and `s <= p`. -fn validate_decimal_arguments(precision: &str, scale: &str) -> Result<(), String> { - let precision = validate_decimal_number("Precision", precision)?; - let scale = validate_decimal_number("Scale", scale)?; +fn validate_decimal_arguments( + locale: crate::i18n::Locale, + precision: &str, + scale: &str, +) -> Result<(), String> { + let precision = validate_decimal_number(locale, "label-precision", precision)?; + let scale = validate_decimal_number(locale, "label-scale", scale)?; if precision < 1 { - return Err("Precision must be at least 1.".to_string()); + return Err(crate::tr!(locale, "error-precision-min")); } if scale > precision { - return Err("Scale cannot be greater than precision.".to_string()); + return Err(crate::tr!(locale, "error-scale-gt-precision")); } Ok(()) } -fn validate_decimal_number(label: &str, value: &str) -> Result { +fn validate_decimal_number( + locale: crate::i18n::Locale, + label_key: &str, + value: &str, +) -> Result { + let label = crate::tr!(locale, label_key); if value.is_empty() { - return Err(format!("{label} is required for a decimal column.")); + return Err(crate::tr!( + locale, + "error-decimal-required", + "label" => label, + )); } if value.starts_with('+') || value.starts_with('-') { - return Err(format!("{label} cannot carry a sign.")); + return Err(crate::tr!( + locale, + "error-decimal-sign", + "label" => label, + )); } if value.contains('.') { - return Err(format!("{label} must be a whole number.")); + return Err(crate::tr!( + locale, + "error-decimal-whole", + "label" => label, + )); } if value.len() > 1 && value.starts_with('0') { - return Err(format!("{label} cannot have leading zeros.")); + return Err(crate::tr!( + locale, + "error-decimal-leading-zeros", + "label" => label, + )); } - value - .parse::() - .map_err(|_| format!("{label} must be a whole number.")) + value.parse::().map_err(|_| { + crate::tr!( + locale, + "error-decimal-whole", + "label" => label, + ) + }) } /// The seam where the rules above meet the generated request types. @@ -1548,7 +1722,7 @@ pub(crate) mod tests { assert!(draft.show_temporal_type()); draft.temporal_type_input = "raw_datetime".to_string(); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert_eq!(draft.added[0].data_type, "raw_datetime"); // Inputs are cleared for the next column. assert!(draft.temporal_type_input.is_empty()); @@ -1556,7 +1730,7 @@ pub(crate) mod tests { draft.name_input = "barcode".to_string(); draft.type_input = "gtin".to_string(); draft.gtin_type_input = "13".to_string(); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert_eq!(draft.added[1].data_type, "gtin_13"); draft.name_input = "weight".to_string(); @@ -1565,7 +1739,7 @@ pub(crate) mod tests { assert!(draft.show_decimal_arguments()); draft.decimal_precision_input = "12".to_string(); draft.decimal_scale_input = "3".to_string(); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert_eq!(draft.added[2].data_type, "decimal(12,3)"); } @@ -1579,7 +1753,7 @@ pub(crate) mod tests { assert_eq!(draft.canonical_type_input().unwrap(), None); draft.link_table_input = "customer".to_string(); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert_eq!(draft.added[0].name, "billing_customer"); assert_eq!(draft.added[0].data_type, "link(customer)"); @@ -1606,14 +1780,14 @@ pub(crate) mod tests { draft.decimal_precision_input = precision.to_string(); draft.decimal_scale_input = scale.to_string(); assert!( - draft.add_from_inputs().is_err(), + draft.add_from_inputs(crate::i18n::Locale::default()).is_err(), "decimal({precision},{scale}) should be refused" ); } draft.decimal_precision_input = "10".to_string(); draft.decimal_scale_input = "0".to_string(); - assert!(draft.add_from_inputs().is_ok()); + assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_ok()); } /// `duration` and `period` are storable types on their own — the picker @@ -1624,7 +1798,7 @@ pub(crate) mod tests { let mut draft = draft(); draft.name_input = "billing_span".to_string(); draft.type_input = field_type.to_string(); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert_eq!(draft.added[0].data_type, field_type); } } @@ -1634,7 +1808,7 @@ pub(crate) mod tests { for field_type in ["accounting", "accounting_transfer"] { let mut draft = ColumnDraft::for_append(catalog()); draft.type_input = field_type.to_string(); - assert!(draft.add_from_inputs().is_err()); + assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err()); // And again for a draft rebuilt from a posted form, which never // went through `add_from_inputs`. @@ -1647,7 +1821,7 @@ pub(crate) mod tests { money_mode: MoneyMode::Exact, currency: String::new(), }); - assert!(draft.validate().is_err()); + assert!(draft.validate(crate::i18n::Locale::default()).is_err()); } } @@ -1657,20 +1831,20 @@ pub(crate) mod tests { draft.type_input = "text".to_string(); draft.name_input = "Total".to_string(); - assert!(draft.add_from_inputs().is_err()); + assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err()); draft.name_input = "created_at".to_string(); - assert!(draft.add_from_inputs().is_err()); + assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err()); draft.name_input = "total".to_string(); draft.type_input = "timestamptz".to_string(); - assert!(draft.add_from_inputs().is_err()); + assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err()); draft.type_input = "text".to_string(); - assert!(draft.add_from_inputs().is_ok()); + assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_ok()); // Duplicates are refused too. draft.name_input = "total".to_string(); draft.type_input = "text".to_string(); - assert!(draft.add_from_inputs().is_err()); + assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err()); } /// A column type the backend generates itself is not one a client may @@ -1681,7 +1855,7 @@ pub(crate) mod tests { draft.name_input = "country_code".to_string(); draft.type_input = "phone_calling_code".to_string(); - assert!(draft.add_from_inputs().is_err()); + assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err()); assert!( catalog() .validate_field_type("phone_calling_code") @@ -1695,10 +1869,10 @@ pub(crate) mod tests { draft.name_input = "note".to_string(); draft.type_input = "text".to_string(); draft.quantity_ledger_input = "yes".to_string(); - assert!(draft.add_from_inputs().is_err()); + assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err()); draft.type_input = "int".to_string(); - assert!(draft.add_from_inputs().is_ok()); + assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_ok()); assert!(draft.added[0].quantity_ledger); // A parameterised decimal counts, through its head. @@ -1707,7 +1881,7 @@ pub(crate) mod tests { draft.decimal_precision_input = "12".to_string(); draft.decimal_scale_input = "3".to_string(); draft.quantity_ledger_input = "yes".to_string(); - assert!(draft.add_from_inputs().is_ok()); + assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_ok()); // And the hint under the input names exactly that set. assert_eq!(draft.quantity_ledger_types(), "BIGINT, DECIMAL, INT, MONEY"); @@ -1725,7 +1899,7 @@ pub(crate) mod tests { if field_type == "accounting" { draft.currency_input = "EUR".to_string(); } - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert_eq!(draft.added[0].name, field_type); assert!(!draft.added[0].indexed); @@ -1741,10 +1915,10 @@ pub(crate) mod tests { draft.name_input = "total".to_string(); draft.type_input = "money".to_string(); draft.currency_input = "EU".to_string(); - assert!(draft.add_from_inputs().is_err()); + assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err()); draft.currency_input = "eur".to_string(); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert_eq!(draft.added[0].currency, "EUR"); } @@ -1755,14 +1929,14 @@ pub(crate) mod tests { let mut draft = draft(); draft.type_input = "accounting".to_string(); draft.currency_input = "czk".to_string(); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert_eq!(draft.added[0].currency, "CZK"); - assert_eq!(draft.added[0].option_label(), "CZK, exact"); + assert_eq!(draft.added[0].option_label(&crate::i18n::Locale::default()), "CZK, exact"); draft.type_input = "accounting_transfer".to_string(); draft.currency_input = "czk".to_string(); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert_eq!(draft.added[1].currency, ""); } @@ -1780,17 +1954,17 @@ pub(crate) mod tests { money_mode: MoneyMode::Exact, currency: String::new(), }); - assert!(draft.validate().is_err()); + assert!(draft.validate(crate::i18n::Locale::default()).is_err()); draft.added[0].currency = "XYZ".to_string(); - assert!(draft.validate().is_err()); + assert!(draft.validate(crate::i18n::Locale::default()).is_err()); draft.added[0].currency = "EUR".to_string(); - assert!(draft.validate().is_ok()); + assert!(draft.validate(crate::i18n::Locale::default()).is_ok()); // Forbidden on everything else, exactly as the server has it. draft.added[0].data_type = "text".to_string(); - assert!(draft.validate().is_err()); + assert!(draft.validate(crate::i18n::Locale::default()).is_err()); } /// And to the quantity-ledger rule, which the panel applies at add time. @@ -1807,7 +1981,7 @@ pub(crate) mod tests { currency: String::new(), }); - assert!(draft.validate().is_err()); + assert!(draft.validate(crate::i18n::Locale::default()).is_err()); } /// With no catalog there is no vocabulary, so nothing is accepted rather @@ -1820,7 +1994,7 @@ pub(crate) mod tests { assert!(!draft.catalog.is_loaded()); assert!(draft.offered_types().is_empty()); - assert!(draft.add_from_inputs().is_err()); + assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err()); } /// The catalog also explains the types `GetProfileDetails` reports back, @@ -1864,7 +2038,7 @@ pub(crate) mod tests { for name in ["number", "issued_on", "total"] { draft.name_input = name.to_string(); draft.type_input = "text".to_string(); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); } let names = |draft: &ColumnDraft| { draft @@ -1875,18 +2049,18 @@ pub(crate) mod tests { }; assert_eq!( - draft.move_column(2, -1), + draft.move_column(crate::i18n::Locale::default(), 2, -1), Some("Column `total` moved up.".to_string()) ); assert_eq!(names(&draft), ["number", "total", "issued_on"]); - draft.move_column(0, 1).unwrap(); + draft.move_column(crate::i18n::Locale::default(), 0, 1).unwrap(); assert_eq!(names(&draft), ["total", "number", "issued_on"]); // Off either end, and past the end of the list entirely. - assert_eq!(draft.move_column(0, -1), None); - assert_eq!(draft.move_column(2, 1), None); - assert_eq!(draft.move_column(9, -1), None); + assert_eq!(draft.move_column(crate::i18n::Locale::default(), 0, -1), None); + assert_eq!(draft.move_column(crate::i18n::Locale::default(), 2, 1), None); + assert_eq!(draft.move_column(crate::i18n::Locale::default(), 9, -1), None); assert_eq!(names(&draft), ["total", "number", "issued_on"]); } @@ -1907,7 +2081,7 @@ pub(crate) mod tests { ["name", "tax_point_date", "debit", "credit", "account"] ); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert_eq!(draft.generated_columns_of(0).len(), 5); // DEBIT and CREDIT are the two kept in the declared currency. assert_eq!( @@ -1923,7 +2097,7 @@ pub(crate) mod tests { draft.name_input = "number".to_string(); draft.type_input = "text".to_string(); assert!(!draft.pending_is_compound()); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert!(draft.generated_columns_of(1).is_empty()); } @@ -1932,7 +2106,7 @@ pub(crate) mod tests { let mut draft = draft(); draft.name_input = "number".to_string(); draft.type_input = "text".to_string(); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); draft.toggle_indexed(0); assert_eq!(draft.selected_index_names(), vec!["number"]); @@ -1954,7 +2128,7 @@ pub(crate) mod tests { assert!(!draft.show_indexing()); assert!(draft.pending_is_auto_indexed()); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); // Asking anyway leaves the column unindexed, so nothing names it. assert!(!draft.added[0].indexed); @@ -1965,12 +2139,12 @@ pub(crate) mod tests { // And it is still reported as indexed, because it is. assert!(draft.added[0].is_indexed()); - assert!(draft.added[0].option_label().contains("indexed")); + assert!(draft.added[0].option_label(&crate::i18n::Locale::default()).contains("indexed")); // A draft rebuilt from a post that says otherwise is refused rather // than sent on to be refused by the server. draft.added[0].indexed = true; - let error = draft.validate().unwrap_err(); + let error = draft.validate(crate::i18n::Locale::default()).unwrap_err(); assert!(error.contains("indexed automatically"), "{error}"); assert!(draft.selected_index_names().is_empty()); } @@ -1983,11 +2157,11 @@ pub(crate) mod tests { let mut draft = draft(); draft.type_input = "accounting".to_string(); draft.currency_input = "EUR".to_string(); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert!(!draft.added[0].indexed); draft.added[0].indexed = true; - let error = draft.validate().unwrap_err(); + let error = draft.validate(crate::i18n::Locale::default()).unwrap_err(); assert!(error.contains("cannot be indexed"), "{error}"); assert!(draft.selected_index_names().is_empty()); } @@ -2001,11 +2175,11 @@ pub(crate) mod tests { assert!(draft.show_indexing()); assert!(!draft.pending_is_auto_indexed()); draft.indexing_input = "yes".to_string(); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert!(draft.is_indexable(0)); assert_eq!(draft.selected_index_names(), vec!["number"]); - assert!(draft.validate().is_ok()); + assert!(draft.validate(crate::i18n::Locale::default()).is_ok()); } /// Both types post to one profile's books, and a shared table belongs to @@ -2024,7 +2198,7 @@ pub(crate) mod tests { for field_type in ["accounting", "accounting_transfer"] { let mut draft = draft.clone(); draft.type_input = field_type.to_string(); - let error = draft.add_from_inputs().unwrap_err(); + let error = draft.add_from_inputs(crate::i18n::Locale::default()).unwrap_err(); assert!(error.contains("shared table"), "{error}"); } @@ -2038,7 +2212,7 @@ pub(crate) mod tests { money_mode: MoneyMode::Exact, currency: "EUR".to_string(), }); - assert!(draft.validate().is_err()); + assert!(draft.validate(crate::i18n::Locale::default()).is_err()); } /// A quantity ledger is one profile's, for the same reason. @@ -2051,7 +2225,7 @@ pub(crate) mod tests { draft.name_input = "quantity".to_string(); draft.type_input = "int".to_string(); draft.quantity_ledger_input = "yes".to_string(); - let error = draft.add_from_inputs().unwrap_err(); + let error = draft.add_from_inputs(crate::i18n::Locale::default()).unwrap_err(); assert!(error.contains("quantity ledger"), "{error}"); draft.added.push(ColumnDefinition { @@ -2063,12 +2237,12 @@ pub(crate) mod tests { money_mode: MoneyMode::Exact, currency: String::new(), }); - assert!(draft.validate().is_err()); + assert!(draft.validate(crate::i18n::Locale::default()).is_err()); // The same column on a profile's own table is fine. draft.global = false; assert!(draft.show_quantity_ledger()); - assert!(draft.validate().is_ok()); + assert!(draft.validate(crate::i18n::Locale::default()).is_ok()); } /// The table does not exist yet, so it cannot be pointed at — the picker @@ -2082,15 +2256,15 @@ pub(crate) mod tests { draft.type_input = "link".to_string(); draft.link_table_input = "invoice".to_string(); - let error = draft.add_from_inputs().unwrap_err(); + let error = draft.add_from_inputs(crate::i18n::Locale::default()).unwrap_err(); assert!(error.contains("cannot point at the table"), "{error}"); // Added while the table had another name, then renamed to the target. draft.table_name = "order".to_string(); - draft.add_from_inputs().unwrap(); - assert!(draft.validate().is_ok()); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); + assert!(draft.validate(crate::i18n::Locale::default()).is_ok()); draft.table_name = "invoice".to_string(); - assert!(draft.validate().is_err()); + assert!(draft.validate(crate::i18n::Locale::default()).is_err()); } /// A row reaches the chart of accounts through an ACCOUNTING definition @@ -2103,7 +2277,7 @@ pub(crate) mod tests { draft.type_input = "link".to_string(); draft.link_table_input = ACCOUNTS_TABLE.to_string(); - let error = draft.add_from_inputs().unwrap_err(); + let error = draft.add_from_inputs(crate::i18n::Locale::default()).unwrap_err(); assert!(error.contains("built into ACCOUNTING"), "{error}"); } @@ -2114,22 +2288,22 @@ pub(crate) mod tests { let mut draft = draft(); draft.type_input = "accounting".to_string(); draft.currency_input = "EUR".to_string(); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert!(draft.claimed_names().contains(&"debit".to_string())); draft.name_input = "debit".to_string(); draft.type_input = "text".to_string(); - assert!(draft.add_from_inputs().is_err(), "ACCOUNTING generates it"); + assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err(), "ACCOUNTING generates it"); // And the other way round: the declared column first, then the row // whose expansion would collide with it. let mut reversed = ColumnDraft::new(catalog()); reversed.name_input = "credit".to_string(); reversed.type_input = "text".to_string(); - reversed.add_from_inputs().unwrap(); + reversed.add_from_inputs(crate::i18n::Locale::default()).unwrap(); reversed.type_input = "accounting".to_string(); reversed.currency_input = "EUR".to_string(); - let error = reversed.add_from_inputs().unwrap_err(); + let error = reversed.add_from_inputs(crate::i18n::Locale::default()).unwrap_err(); assert!(error.contains("credit"), "{error}"); // A companion named after its own column follows that column's name, @@ -2138,10 +2312,10 @@ pub(crate) mod tests { for name in ["home_phone", "work_phone"] { phones.name_input = name.to_string(); phones.type_input = "phone".to_string(); - phones.add_from_inputs().unwrap(); + phones.add_from_inputs(crate::i18n::Locale::default()).unwrap(); } assert!(phones.claimed_names().contains(&"work_phone_ext".to_string())); - assert!(phones.validate().is_ok()); + assert!(phones.validate(crate::i18n::Locale::default()).is_ok()); } /// `required` is a column property the server records and enforces on every @@ -2152,10 +2326,10 @@ pub(crate) mod tests { draft.name_input = "number".to_string(); draft.type_input = "text".to_string(); draft.required_input = "yes".to_string(); - draft.add_from_inputs().unwrap(); + draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert!(draft.added[0].required); - assert!(draft.added[0].option_label().contains("required")); + assert!(draft.added[0].option_label(&crate::i18n::Locale::default()).contains("required")); assert!(proto_columns(&draft.added)[0].required); // And the panel is cleared for the next column, which is not required diff --git a/web/src/ui/mod.rs b/web/src/ui/mod.rs index 973b91a7..548f4fcb 100644 --- a/web/src/ui/mod.rs +++ b/web/src/ui/mod.rs @@ -55,6 +55,33 @@ impl Nav { self.locale.lookup(key) } + /// Like [`Self::tr`], but interpolates named `{ $name }` placeholders. + /// Values are strings; a template needing a *number* (for Fluent plural + /// selection) should have the label built in Rust instead, where the + /// `tr!` macro accepts `i64` arguments. + #[allow(dead_code)] + pub(crate) fn tr_args(&self, key: &str, args: &[(&str, String)]) -> String { + use std::{borrow::Cow, collections::HashMap}; + let map: HashMap, fluent_templates::fluent_bundle::FluentValue<'_>> = + args.iter() + .map(|(name, value)| (Cow::Owned(name.to_string()), value.clone().into())) + .collect(); + self.locale.lookup_args(key, &map) + } + + /// Translates a key with one numeric argument, for Fluent plural + /// selection (`{ $count -> [one] … *[other] … }`). + #[allow(dead_code)] + pub(crate) fn tr_count(&self, key: &str, name: &str, count: &i64) -> String { + use std::{borrow::Cow, collections::HashMap}; + let mut map: HashMap< + Cow<'static, str>, + fluent_templates::fluent_bundle::FluentValue<'_>, + > = HashMap::new(); + map.insert(Cow::Owned(name.to_string()), (*count).into()); + self.locale.lookup_args(key, &map) + } + pub(crate) fn with_authorization( mut self, authorization: &crate::auth::AuthorizationSnapshot, @@ -97,22 +124,27 @@ impl Default for Nav { #[derive(Template)] #[template(path = "ui/alert_fragment.html")] pub(crate) struct Alert<'a> { + /// The request's language, which the shared alert/dialog markup needs for + /// its own chrome (the dismiss aria-label, the "Back to the form" button). + locale: crate::i18n::Locale, success: bool, title: &'a str, message: &'a str, } impl<'a> Alert<'a> { - pub(crate) fn error(title: &'a str, message: &'a str) -> Self { + pub(crate) fn error(locale: crate::i18n::Locale, title: &'a str, message: &'a str) -> Self { Self { + locale, success: false, title, message, } } - pub(crate) fn success(title: &'a str, message: &'a str) -> Self { + pub(crate) fn success(locale: crate::i18n::Locale, title: &'a str, message: &'a str) -> Self { Self { + locale, success: true, title, message, @@ -124,22 +156,25 @@ impl<'a> Alert<'a> { #[derive(Template)] #[template(path = "ui/notice.html")] pub(crate) struct Notice<'a> { + locale: crate::i18n::Locale, error: bool, message: &'a str, login_link: bool, } impl<'a> Notice<'a> { - pub(crate) fn error(message: &'a str) -> Self { + pub(crate) fn error(locale: crate::i18n::Locale, message: &'a str) -> Self { Self { + locale, error: true, message, login_link: false, } } - pub(crate) fn login_required(message: &'a str) -> Self { + pub(crate) fn login_required(locale: crate::i18n::Locale, message: &'a str) -> Self { Self { + locale, error: true, message, login_link: true, @@ -172,7 +207,11 @@ mod tests { /// place that decides a failure cannot go unnoticed. #[test] fn a_failed_form_gets_a_dialog_and_a_successful_one_does_not() { - let failure = render(&Alert::error("Could not save", "A name is required.")); + let failure = render(&Alert::error( + crate::i18n::Locale::default(), + "Could not save", + "A name is required.", + )); assert!(!failure.contains("Template error"), "{failure}"); assert!(failure.contains(r#"role="alert""#)); assert!(failure.contains(r#"role="dialog""#)); @@ -182,7 +221,11 @@ mod tests { // A success only asks the toast stack in `ui/base.html` to show it: it // dismisses itself, so it neither blocks the page nor stays behind in // the layout, and it renders nothing of its own here. - let success = render(&Alert::success("Saved", "Two rows written.")); + let success = render(&Alert::success( + crate::i18n::Locale::default(), + "Saved", + "Two rows written.", + )); assert!(!success.contains("Template error"), "{success}"); assert!(success.contains("$dispatch('notify'")); assert!(success.contains(r#"data-message="Two rows written.""#)); diff --git a/web/templates/pages/add_logic/add_logic.html b/web/templates/pages/add_logic/add_logic.html index 3be221d4..8735bfec 100644 --- a/web/templates/pages/add_logic/add_logic.html +++ b/web/templates/pages/add_logic/add_logic.html @@ -2,36 +2,36 @@ {% extends "ui/form_page.html" %} {% import "ui/alert.html" as alert %} -{% block title %}Add logic{% endblock %} -{% block eyebrow %}Computed column{% endblock %} -{% block heading %}Add logic{% endblock %} -{% block lead %}

Create or update a Steel script through the existing table-script service.

{% endblock %} +{% block title %}{{ nav.tr("add-logic-title") }}{% endblock %} +{% block eyebrow %}{{ nav.tr("add-logic-eyebrow") }}{% endblock %} +{% block heading %}{{ nav.tr("add-logic-title") }}{% endblock %} +{% block lead %}

{{ nav.tr("add-logic-lead") }}

{% endblock %} {% block form %}
-
- {%- if let Some(message) = page.error %}{% call alert::error("Could not save the logic", message) %}{% endcall %}{% endif -%} + {%- if let Some(message) = page.error %}{% call alert::error(nav.locale, nav.tr("add-logic-error-title"), message) %}{% endcall %}{% endif -%}
- Cancel - + {{ nav.tr("common-cancel") }} +
{% endblock %} diff --git a/web/templates/pages/add_table/add_table.html b/web/templates/pages/add_table/add_table.html index 2057fe92..a4faab88 100644 --- a/web/templates/pages/add_table/add_table.html +++ b/web/templates/pages/add_table/add_table.html @@ -1,10 +1,10 @@ {# GET /admin/tables/new — crate::pages::add_table::ui::AddTablePage #} {% extends "ui/form_page.html" %} -{% block title %}Add table{% endblock %} -{% block eyebrow %}Table definition{% endblock %} -{% block heading %}Add table{% endblock %} -{% block lead %}

Choose whether the table belongs to one profile or is available to all profiles, then add its columns. After creation, you can assign the initial role permissions.

{% endblock %} +{% block title %}{{ nav.tr("add-table-title") }}{% endblock %} +{% block eyebrow %}{{ nav.tr("add-table-eyebrow") }}{% endblock %} +{% block heading %}{{ nav.tr("add-table-title") }}{% endblock %} +{% block lead %}

{{ nav.tr("add-table-lead") }}

{% endblock %} {% block form %} {# diff --git a/web/templates/pages/add_table/builder.html b/web/templates/pages/add_table/builder.html index 4f1487d3..9f0d25ff 100644 --- a/web/templates/pages/add_table/builder.html +++ b/web/templates/pages/add_table/builder.html @@ -17,16 +17,16 @@ down to the column list or the Create table button. The alert stays too, as what is left on the page after the dialog is dismissed. #} -{%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%} -{%- if let Some(message) = page.status %}{% call toast::success("Draft updated", message) %}{% endcall %}{% endif -%} -{%- if let Some(message) = page.error %}{% call dialog::error("Could not continue", message) %}{% endcall %}{% endif -%} +{%- if let Some(message) = page.error %}{% call alert::error(nav.locale, nav.tr("builder-could-not-continue"), message) %}{% endcall %}{% endif -%} +{%- if let Some(message) = page.status %}{% call toast::success(nav.tr("builder-draft-updated"), message) %}{% endcall %}{% endif -%} +{%- if let Some(message) = page.error %}{% call dialog::error(nav.locale, nav.tr("builder-could-not-continue"), message) %}{% endcall %}{% endif -%}
-

Table details

+

{{ nav.tr("builder-table-details") }}

{# Penguin UI custom radio, adapted from radio/custom-radio.html with this form's values and htmx behaviour. #}
- Where should this table be available? + {{ nav.tr("builder-scope-question") }}
{% if !page.draft.global %} -
-

Add a column

+

{{ nav.tr("builder-add-column-heading") }}

{# A compound choice is a definition row, not a column: it is named after @@ -111,26 +111,26 @@ nothing to name, so the field is replaced by what the choice will add. #} {% if !page.draft.columns.pending_is_compound() %} -
{% if page.draft.global %} -

A shared table keeps no books of its own, so ACCOUNTING, ACCOUNTING_TRANSFER and quantity-ledger columns are not offered here — each of them posts to one profile's ledger.

+

{{ nav.tr("builder-shared-table-hint") }}

{% endif %} {% if page.draft.columns.pending_is_compound() %}
-

{{ page.draft.columns.pending_compound_name() }} is a definition row, not a column of its own. Adding it gives the table:

+

{{ page.draft.columns.pending_compound_name() }} {{ nav.tr("builder-compound-intro") }}

    {% for generated in page.draft.columns.pending_generated_columns() %} {# The currency and rounding are named rather than shown: those two inputs sit above with no `change` trigger of their own, so their current values are only known here on the next render. #} -
  • {{ generated.name }} {{ generated.data_type }}{% if generated.inherits_currency %} — kept in the currency and rounding chosen above{% endif %}
  • +
  • {{ generated.name }} {{ generated.data_type }}{% if generated.inherits_currency %} — {{ nav.tr("builder-inherits-currency-hint") }}{% endif %}
  • {% endfor %} {% if page.draft.columns.pending_compound_name() == "accounting" %} -
  • account_id system column — the account each row is posted to, written as account
  • +
  • account_id {{ nav.tr("builder-account-system-hint")|safe }}
  • {% endif %}
-

Each of these can be given a name of your own under Rename generated columns once the row is added; leave a field empty to keep the name shown here.

-

These names are reserved: no column of your own may use them, and the table may hold only one {{ page.draft.columns.pending_compound_name() }}. They land where this row sits in the column list, and can be moved with it.

+

{{ nav.tr("builder-rename-generated-hint")|safe }}

+

{{ nav.tr_args("builder-reserved-hint", [("type", page.draft.columns.pending_compound_name())])|safe }}

{% endif %} + hx-target="#builder" hx-swap="innerHTML" hx-vals='{"action": "add-column"}'>{{ nav.tr("builder-add-column-button") }}
-

Columns {{ page.draft.columns.added.len() }}

+

{{ nav.tr("builder-columns-heading") }} {{ page.draft.columns.added.len() }}

{% if page.draft.columns.is_empty() %} -

No columns yet. Describe one above and press Add column.

+

{{ nav.tr("builder-no-columns") }} {{ nav.tr("builder-add-column-button") }}.

{% else %} -

Columns are created in this order. Use the arrows to move one.

+

{{ nav.tr("builder-columns-order-hint") }}

- + {# Every declared column, each followed by the columns the server will @@ -290,11 +290,11 @@ @@ -369,8 +369,8 @@ {# Open once a name has been asked for: the section is where those names are read back, and a swap that closed it would hide what was just applied. #}
- Rename generated columns -

These columns come from the definition rows above. Give one a name of your own, or leave it empty to keep the name shown.

+ {{ nav.tr("builder-rename-generated") }} +

{{ nav.tr("builder-rename-generated-hint2") }}

    {% for row in page.alias_rows() %}
  • @@ -378,7 +378,7 @@ {{ row.data_type }} + aria-label="{{ nav.tr_args("builder-rename-aria", [("name", row.source.clone())]) }}" placeholder="{{ row.source }}">
  • {% endfor %}
@@ -389,7 +389,7 @@ #} + hx-vals='{"action": "apply-generated-names"}'>{{ nav.tr("builder-apply-names") }}
{% endif %} @@ -401,8 +401,8 @@
-

Row display columns

-

What identifies a row to users, in the order chosen. Pick id to clear the list.

+

{{ nav.tr("builder-row-display") }}

+

{{ nav.tr("builder-row-display-hint")|safe }}

    {% for candidate in page.row_display_candidates() %}
  • @@ -420,11 +420,11 @@
-

Table definition preview

+

{{ nav.tr("builder-preview") }}

OrderNameTypeIndexedOptions
{{ nav.tr("builder-th-order") }}{{ nav.tr("builder-th-name") }}{{ nav.tr("builder-th-type") }}{{ nav.tr("builder-th-indexed") }}{{ nav.tr("builder-th-options") }}
{% if let Some(index) = row.index %} - - @@ -306,7 +306,7 @@ {{ row.name }} {# The list only reports a rename; it is asked for below, so that this column stays something to read. #} - {% if !row.alias.is_empty() %}renamed to {{ row.alias }}{% endif %} + {% if !row.alias.is_empty() %}{{ nav.tr_args("builder-renamed-to", [("alias", row.alias.clone())]) }}{% endif %} {{ row.data_type }} @@ -323,7 +323,7 @@ {% else if row.indexed %} {# Not a choice anyone made: a link and the account foreign key are indexed by the server as it creates the table. #} - indexed automatically + {{ nav.tr("td-indexed-automatically-tag") }} {% else %} {% endif %} @@ -333,7 +333,7 @@ {% if let Some(index) = row.index %} + hx-vals='{"action": "remove-column", "index": "{{ index }}"}'>{{ nav.tr("common-remove") }} {% endif %}
- + - {% for row in page.draft.preview_rows() %} + {% for row in page.draft.preview_rows(nav.locale) %} @@ -438,6 +438,6 @@
- Cancel - + {{ nav.tr("common-cancel") }} +
diff --git a/web/templates/pages/add_validation/field.html b/web/templates/pages/add_validation/field.html index bd1ac6d2..d50620c0 100644 --- a/web/templates/pages/add_validation/field.html +++ b/web/templates/pages/add_validation/field.html @@ -2,31 +2,31 @@ {% extends "ui/form_page.html" %} {% import "ui/alert.html" as alert %} -{% block title %}Add field validation{% endblock %} -{% block eyebrow %}Validation{% endblock %} -{% block heading %}Add field validation{% endblock %} -{% block lead %}

Attach validation to one column of one table.

{% endblock %} +{% block title %}{{ nav.tr("validation-field-title") }}{% endblock %} +{% block eyebrow %}{{ nav.tr("validation-eyebrow") }}{% endblock %} +{% block heading %}{{ nav.tr("validation-field-title") }}{% endblock %} +{% block lead %}

{{ nav.tr("validation-field-lead") }}

{% endblock %} {% block form %}
- - - -
{% for profile in page.profiles %}{% endfor %}{% for table in page.tables %}{% endfor %}
- {%- if let Some(message) = page.error %}{% call alert::error("Could not save validation", message) %}{% endcall %}{% endif -%} + {%- if let Some(message) = page.error %}{% call alert::error(nav.locale, nav.tr("validation-error-title"), message) %}{% endcall %}{% endif -%}
- Cancel - + {{ nav.tr("common-cancel") }} +
{% endblock %} diff --git a/web/templates/pages/add_validation/rule.html b/web/templates/pages/add_validation/rule.html index bb912b7c..61c72d86 100644 --- a/web/templates/pages/add_validation/rule.html +++ b/web/templates/pages/add_validation/rule.html @@ -2,24 +2,24 @@ {% extends "ui/form_page.html" %} {% import "ui/alert.html" as alert %} -{% block title %}Add reusable validation rule{% endblock %} -{% block eyebrow %}Global validation library{% endblock %} -{% block heading %}Add reusable validation rule{% endblock %} -{% block lead %}

A named rule that validation sets can compose, independent of any table.

{% endblock %} +{% block title %}{{ nav.tr("validation-rule-title") }}{% endblock %} +{% block eyebrow %}{{ nav.tr("validation-library-eyebrow") }}{% endblock %} +{% block heading %}{{ nav.tr("validation-rule-title") }}{% endblock %} +{% block lead %}

{{ nav.tr("validation-rule-lead") }}

{% endblock %} {% block form %}
- - + + {% include "pages/add_validation/shared_fields.html" %}
- {%- if let Some(message) = page.error %}{% call alert::error("Could not save validation", message) %}{% endcall %}{% endif -%} + {%- if let Some(message) = page.error %}{% call alert::error(nav.locale, nav.tr("validation-error-title"), message) %}{% endcall %}{% endif -%}
- Cancel - + {{ nav.tr("common-cancel") }} +
{% endblock %} diff --git a/web/templates/pages/add_validation/set.html b/web/templates/pages/add_validation/set.html index 9a810b0e..37685bea 100644 --- a/web/templates/pages/add_validation/set.html +++ b/web/templates/pages/add_validation/set.html @@ -2,27 +2,27 @@ {% extends "ui/form_page.html" %} {% import "ui/alert.html" as alert %} -{% block title %}Add validation set{% endblock %} -{% block eyebrow %}Global validation library{% endblock %} -{% block heading %}Add validation set{% endblock %} -{% block lead %}

Compose an ordered set from existing reusable rule names.

{% endblock %} +{% block title %}{{ nav.tr("validation-set-title") }}{% endblock %} +{% block eyebrow %}{{ nav.tr("validation-library-eyebrow") }}{% endblock %} +{% block heading %}{{ nav.tr("validation-set-title") }}{% endblock %} +{% block lead %}

{{ nav.tr("validation-set-lead") }}

{% endblock %} {% block form %}
- - -
- {%- if let Some(message) = error %}{% call alert::error("Could not save validation", message) %}{% endcall %}{% endif -%} + {%- if let Some(message) = error %}{% call alert::error(nav.locale, nav.tr("validation-error-title"), message) %}{% endcall %}{% endif -%}
- Cancel - + {{ nav.tr("common-cancel") }} +
{% endblock %} diff --git a/web/templates/pages/add_validation/shared_fields.html b/web/templates/pages/add_validation/shared_fields.html index cb307fed..9013407b 100644 --- a/web/templates/pages/add_validation/shared_fields.html +++ b/web/templates/pages/add_validation/shared_fields.html @@ -2,33 +2,32 @@ Validation inputs shared by the field-validation and reusable-rule pages. Reads `page.form` (crate::pages::add_validation::state::ValidationForm). #} -
ColumnTypeOptionsSource
{{ nav.tr("builder-preview-th-column") }}{{ nav.tr("builder-preview-th-type") }}{{ nav.tr("builder-preview-th-options") }}{{ nav.tr("builder-preview-th-source") }}
{{ row.mark }} {{ row.column }}
- - - + + + @@ -48,24 +40,24 @@ - +
#ResultStartedFinishedTookWindowObservationsVerified through#{{ nav.tr("ecb-th-result") }}{{ nav.tr("ecb-th-started") }}{{ nav.tr("ecb-th-finished") }}{{ nav.tr("ecb-th-took") }}{{ nav.tr("ecb-th-window") }}{{ nav.tr("ecb-th-observations") }}{{ nav.tr("ecb-th-verified-through") }}
{{ batch.batch_id }} - {%- if batch.succeeded() %}succeeded - {%- else if batch.running() %}running - {%- else %}failed{% endif -%} + {%- if batch.succeeded() %}{{ nav.tr("ecb-batch-succeeded") }} + {%- else if batch.running() %}{{ nav.tr("ecb-batch-running") }} + {%- else %}{{ nav.tr("ecb-batch-failed") }}{% endif -%} {{ batch.started() }} {%- if let Some(finished) = batch.finished() %}{{ finished }} - {%- else %}still running{% endif -%} + {%- else %}{{ nav.tr("ecb-still-running") }}{% endif -%} - {%- if let Some(took) = batch.took() %}{{ took }} + {%- if let Some(took) = batch.took(nav.locale) %}{{ took }} {%- else %}{% endif -%} {{ batch.window() }}
{{ batch.endpoint }}
{{ batch.observations() }}{{ batch.observations(nav.locale) }} {%- if let Some(date) = batch.verified_through_date %}{{ date }} {%- else %}{% endif -%} @@ -81,11 +73,7 @@
-

- Attempts are an audit record: they cannot be edited or deleted, and a batch that - re-fetched a day an earlier one already owned inserts nothing, which is why the - new count can be lower than the total. -

+

{{ nav.tr("ecb-audit-hint") }}

{% endif %}
diff --git a/web/templates/pages/admin/ecb/status.html b/web/templates/pages/admin/ecb/status.html index 52e8a9fe..48ea1322 100644 --- a/web/templates/pages/admin/ecb/status.html +++ b/web/templates/pages/admin/ecb/status.html @@ -12,7 +12,7 @@ id="ecb-status">

{{ page.headline() }} - {% if page.import_running %}import running{% endif %} + {% if page.import_running %}{{ nav.tr("ecb-import-running") }}{% endif %}

{{ page.explanation() }}

@@ -21,28 +21,24 @@ rule the backend applies -- so the backend applies it and sends both bounds, rather than the page inviting the reader to do it in their head. #}
-

What can be posted right now

+

{{ nav.tr("ecb-postable-lead") }}

{% if let Some(through) = page.transactions_postable_through %}
-
Ordinary transactions
-
dated up to and including {{ through }}
-
Converted at the rate published the business day before the transaction.
+
{{ nav.tr("ecb-ordinary-transactions") }}
+
{{ nav.tr("ecb-dated-up-to") }} {{ through }}
+
{{ nav.tr("ecb-ordinary-rate-hint") }}
-
Statements & decisive dates
-
dated up to and including {{ page.statements_postable_through.as_deref().unwrap_or("—") }}
-
Converted at the rate published on that day, or the last business day before it.
+
{{ nav.tr("ecb-statements-title") }}
+
{{ nav.tr("ecb-dated-up-to") }} {{ page.statements_postable_through.as_deref().unwrap_or("—") }}
+
{{ nav.tr("ecb-statements-rate-hint") }}
-

- A foreign-currency amount dated after its limit is refused outright until the - importer catches up — it is not converted at a stale rate. -

+

{{ nav.tr("ecb-postable-limit-hint") }}

{% else %}

- Nothing. No publication has ever been verified, so every - foreign-currency amount is refused whatever its date. + {{ nav.tr("ecb-nothing") }} {{ nav.tr("ecb-no-publication-hint") }}

{% endif %}
@@ -52,40 +48,40 @@ them read as trivia. #}
- Verified through + {{ nav.tr("ecb-verified-through") }} {%- if let Some(date) = page.verified_through_date %}{{ date }} - {%- else %}never{% endif -%} + {%- else %}{{ nav.tr("ecb-never") }}{% endif -%}
- {%- if page.healthy %}up to date - {%- else if page.days_behind > 0 %}{{ page.days_behind }} publication day{% if page.days_behind != 1 %}s{% endif %} behind - {%- else %}no coverage{% endif -%} + {%- if page.healthy %}{{ nav.tr("ecb-up-to-date") }} + {%- else if page.days_behind > 0 %}{{ page.behind_label() }} + {%- else %}{{ nav.tr("ecb-no-coverage") }}{% endif -%}
- Should reach + {{ nav.tr("ecb-should-reach") }} {{ page.latest_verifiable_date }}
-
Last attempt
-
{% if let Some(when) = page.last_attempt() %}{{ when }}{% else %}none recorded{% endif %}
+
{{ nav.tr("ecb-last-attempt") }}
+
{% if let Some(when) = page.last_attempt() %}{{ when }}{% else %}{{ nav.tr("ecb-none-recorded") }}{% endif %}
-
Last success
-
{% if let Some(when) = page.last_success() %}{{ when }}{% else %}none{% endif %}
+
{{ nav.tr("ecb-last-success") }}
+
{% if let Some(when) = page.last_success() %}{{ when }}{% else %}{{ nav.tr("ecb-none") }}{% endif %}
-
Next import
+
{{ nav.tr("ecb-next-import") }}
{{ page.next_import() }}
-
Currencies on {% if let Some(date) = page.verified_through_date %}{{ date }}{% else %}the covered day{% endif %}
+
{% if let Some(date) = page.verified_through_date %}{{ nav.tr_args("ecb-currencies-on", [("date", date.clone())]) }}{% else %}{{ nav.tr("ecb-the-covered-day") }}{% endif %}
- {%- if page.covered_currencies.is_empty() %}none + {%- if page.covered_currencies.is_empty() %}{{ nav.tr("ecb-none") }} {%- else %}{{ page.covered_currencies.len() }}{% endif -%}
@@ -99,8 +95,8 @@ {% if let Some(failure) = page.last_failure() %}

- Most recent failure — batch {{ failure.batch_id }}, started {{ failure.started() }}: - {% if let Some(message) = failure.error_message %}{{ message }}{% else %}no reason was recorded.{% endif %} + {{ nav.tr_args("ecb-most-recent-failure", [("batch", failure.batch_id.to_string()), ("started", failure.started())]) }} + {% if let Some(message) = failure.error_message %}{{ message }}{% else %}{{ nav.tr("ecb-no-reason-recorded") }}{% endif %}

{% endif %} diff --git a/web/templates/pages/admin/table_definition/column_panel.html b/web/templates/pages/admin/table_definition/column_panel.html index 0d739abe..3641d224 100644 --- a/web/templates/pages/admin/table_definition/column_panel.html +++ b/web/templates/pages/admin/table_definition/column_panel.html @@ -17,33 +17,33 @@ {% import "ui/dialog.html" as panel_dialog %} {% import "ui/toast.html" as panel_toast %} {% if standalone_column_panel %} - {%- if let Some(message) = page.error %}{% call panel_alert::error("Could not add the column", message) %}{% endcall %}{% endif -%} - {%- if let Some(message) = page.status %}{% call panel_toast::success("Staged", message) %}{% endcall %}{% endif -%} - {%- if let Some(message) = page.error %}{% call panel_dialog::error("Could not add the column", message) %}{% endcall %}{% endif -%} + {%- if let Some(message) = page.error %}{% call panel_alert::error(nav.locale, nav.tr("td-could-not-add-column"), message) %}{% endcall %}{% endif -%} + {%- if let Some(message) = page.status %}{% call panel_toast::success(nav.tr("td-staged"), message) %}{% endcall %}{% endif -%} + {%- if let Some(message) = page.error %}{% call panel_dialog::error(nav.locale, nav.tr("td-could-not-add-column"), message) %}{% endcall %}{% endif -%} {% endif %}
-
+ hx-vals='{"action": "add-column"}'>{{ nav.tr("td-stage-column") }} {% if page.columns.added.is_empty() %} -

No columns staged yet. Describe one above and press Stage column.

+

{{ nav.tr("td-no-staged-columns") }} {{ nav.tr("td-stage-column") }}.

{% else %} - + {% for column in page.columns.added %} @@ -182,21 +182,21 @@ {% if column.indexed %}[x]{% else %}[ ]{% endif %} {% else if column.is_indexed() %} - indexed automatically + {{ nav.tr("td-indexed-automatically-tag") }} {% else %} {% endif %} {% endfor %} @@ -216,5 +216,5 @@ {% endfor %}
- +
diff --git a/web/templates/pages/admin/table_definition/columns.html b/web/templates/pages/admin/table_definition/columns.html index 506441b6..ee0e391a 100644 --- a/web/templates/pages/admin/table_definition/columns.html +++ b/web/templates/pages/admin/table_definition/columns.html @@ -1,7 +1,7 @@ {# GET /admin/tables/columns — crate::pages::admin::table_definition::ui::ColumnsPage #} {% extends "ui/base.html" %} -{% block title %}Columns{% endblock %} +{% block title %}{{ nav.tr("td-columns-title") }}{% endblock %} {% block content %}
diff --git a/web/templates/pages/admin/table_definition/columns_panel.html b/web/templates/pages/admin/table_definition/columns_panel.html index 30ab39cf..b00969d9 100644 --- a/web/templates/pages/admin/table_definition/columns_panel.html +++ b/web/templates/pages/admin/table_definition/columns_panel.html @@ -12,8 +12,8 @@ Add-table builder posts, and both are decoded by the same code. #}
-

Add columns to {{ page.selection.table }}

-

Columns are appended. Nothing that already exists is changed, and the new columns can be indexed as they are added.

+

{{ nav.tr("td-add-columns-to") }} {{ page.selection.table }}

+

{{ nav.tr("td-append-hint") }}

-

Rename a column

-

Renames what the column is called, not the physical column underneath, so stored data and scripts are untouched. Only possible while the table has no rows.

+

{{ nav.tr("td-rename-column") }}

+

{{ nav.tr("td-rename-hint") }}

-
- +
@@ -55,16 +55,16 @@ {% if let Some(detail) = page.detail %}
-

Columns it has now {{ detail.columns.len() }}

+

{{ nav.tr("td-columns-now") }} {{ detail.columns.len() }}

NameTypeIndexedOptions
{{ nav.tr("td-col-name") }}{{ nav.tr("td-col-type") }}{{ nav.tr("td-col-indexed") }}{{ nav.tr("td-col-options") }}
- {% if column.required %}required{% endif %} - {% if column.quantity_ledger %}quantity ledger{% endif %} - {% if !column.currency.is_empty() %}{{ column.currency }}, {{ column.money_mode.label() }}{% endif %} + {% if column.required %}{{ nav.tr("column-flag-required") }}{% endif %} + {% if column.quantity_ledger %}{{ nav.tr("column-flag-quantity-ledger") }}{% endif %} + {% if !column.currency.is_empty() %}{{ column.currency }}, {{ column.money_mode.display_label(nav.locale) }}{% endif %} + hx-vals='{"action": "remove-column", "index": "{{ loop.index0 }}"}'>{{ nav.tr("common-remove") }}
- + {% for column in detail.columns %} {% endfor %} @@ -72,9 +72,9 @@
ColumnTypeNotes
{{ nav.tr("td-col-column") }}{{ nav.tr("td-col-type") }}{{ nav.tr("td-col-notes") }}
{{ column.name }} {{ column.field_type }}{% if !column.sql_type.is_empty() %} {{ column.sql_type }}{% endif %} - {%- for flag in column.flags() %}{{ flag }}{% endfor -%} + {%- for flag in column.flags(nav.locale) %}{{ flag }}{% endfor -%}
{% if !detail.scripts.is_empty() %} -

Scripts

+

{{ nav.tr("td-scripts") }}

- + {% for script in detail.scripts %} @@ -82,7 +82,7 @@ {% endfor %} diff --git a/web/templates/pages/admin/table_definition/context.html b/web/templates/pages/admin/table_definition/context.html index bc9f011b..0e436b54 100644 --- a/web/templates/pages/admin/table_definition/context.html +++ b/web/templates/pages/admin/table_definition/context.html @@ -13,64 +13,60 @@

- {%- if page.selection.has_table() %}{{ page.selection.scope_label() }}{% else %}Table definition{% endif -%} + {%- if page.selection.has_table() %}{{ page.selection.scope_label(nav.locale) }}{% else %}{{ nav.tr("td-eyebrow-table-definition") }}{% endif -%}

- {%- if page.selection.has_table() %}{{ page.selection.table }}{% else %}{{ page.selection.scope_label() }}{% endif -%} + {%- if page.selection.has_table() %}{{ page.selection.table }}{% else %}{{ page.selection.scope_label(nav.locale) }}{% endif -%}

{%- if let Some(detail) = page.detail -%} - {{ detail.columns.len() }} columns · identified by - {%- if detail.row_display_columns.is_empty() %} its id + {{ nav.tr_count("td-columns-count", "count", detail.columns.len() as i64) }} · {{ nav.tr("td-identified-by") }} + {%- if detail.row_display_columns.is_empty() %} {{ nav.tr("td-its-id") }} {%- else %} {{ detail.row_display_columns|join(", ") }}{% endif -%} {%- if let Some(summary) = page.selected_table() -%} - {%- if !summary.depends_on.is_empty() %} · depends on {{ summary.depends_on|join(", ") }}{% endif -%} + {%- if !summary.depends_on.is_empty() %} · {{ nav.tr("td-depends-on-prefix") }} {{ summary.depends_on|join(", ") }}{% endif -%} {%- endif -%} . {%- else -%} - Everything on this page acts on this profile. + {{ nav.tr("td-acts-on-profile") }} {%- endif -%}

-
Target columnTypeDescription
{{ nav.tr("td-col-target-column") }}{{ nav.tr("td-col-type") }}{{ nav.tr("td-col-description") }}
{{ script.target_column_type }} {{ script.description }} -
source
{{ script.script }}
+
{{ nav.tr("td-source") }}
{{ script.script }}
- + {% for generated in page.generated %} - + {% endfor %} @@ -23,31 +23,26 @@ {% endif %}
-

Create tables from an invoice template

-

- Reads the #let komp_ac_fields = (…) contract out of a Typst - template. A path that matches an existing table and column becomes a - reference to it; anything else becomes a TEXT column to refine later, and - every [] becomes a child table. -

+

{{ nav.tr("td-template-create-heading") }}

+

{{ nav.tr("td-template-hint")|safe }}

-
- +
diff --git a/web/templates/pages/admin/table_definition/history.html b/web/templates/pages/admin/table_definition/history.html index 0d4788c5..e6b3fe1e 100644 --- a/web/templates/pages/admin/table_definition/history.html +++ b/web/templates/pages/admin/table_definition/history.html @@ -7,21 +7,23 @@ #} {% extends "ui/base.html" %} -{% block title %}Rename history{% endblock %} +{% block title %}{{ nav.tr("td-history-title") }}{% endblock %} {% block content %}
{% include "pages/admin/table_definition/context.html" %}
-

Column rename history

+

{{ nav.tr("td-history-heading") }}

{% if page.history.is_empty() %} -

- No column in {% if page.selection.has_table() %}{{ page.selection.table }}{% else %}this profile{% endif %} has been renamed. -

+ {% if page.selection.has_table() %} +

{{ nav.tr_args("td-history-empty", [("scope", page.selection.table.clone())]) }}

+ {% else %} +

{{ nav.tr("td-history-empty-this-profile") }}

+ {% endif %} {% else %}
TableCollectionParent
{{ nav.tr("td-col-table") }}{{ nav.tr("td-col-collection") }}{{ nav.tr("td-col-parent") }}
{{ generated.table_name }}{% if generated.collection_path.is_empty() %}root{% else %}{{ generated.collection_path }}{% endif %}{% if generated.collection_path.is_empty() %}{{ nav.tr("td-root") }}{% else %}{{ generated.collection_path }}{% endif %} {% if generated.parent_table_name.is_empty() %}{% else %}{{ generated.parent_table_name }}{% endif %}
- + {% for entry in page.history %} diff --git a/web/templates/pages/analytics/analytics.html b/web/templates/pages/analytics/analytics.html index 9579c28d..bcb3a54c 100644 --- a/web/templates/pages/analytics/analytics.html +++ b/web/templates/pages/analytics/analytics.html @@ -1,7 +1,7 @@ {# GET / — crate::pages::analytics::ui::AnalyticsPage #} {% extends "ui/base.html" %} -{% block title %}Analytics{% endblock %} +{% block title %}{{ nav.tr("analytics-title") }}{% endblock %} {% block head %} @@ -12,52 +12,52 @@
-

Reporting

-

Analytics

-

Query the read-only analytics API and chart the result.

+

{{ nav.tr("analytics-eyebrow") }}

+

{{ nav.tr("analytics-title") }}

+

{{ nav.tr("analytics-description") }}

-
- -
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}{% endfor %}{% endfor -%}
- Cancel - + {{ nav.tr("common-cancel") }} +
{% endblock %} diff --git a/web/templates/pages/import_export/import/import.html b/web/templates/pages/import_export/import/import.html index 3bf73f85..b31fd86c 100644 --- a/web/templates/pages/import_export/import/import.html +++ b/web/templates/pages/import_export/import/import.html @@ -2,24 +2,24 @@ {% extends "ui/form_page.html" %} {% import "ui/alert.html" as alert %} -{% block title %}Import CSV{% endblock %} -{% block eyebrow %}Data transfer{% endblock %} -{% block heading %}Import CSV{% endblock %} -{% block lead %}

Choose a browser-local file or paste CSV. Rows are validated against live table structures before gRPC bulk insertion.

{% endblock %} +{% block title %}{{ nav.tr("import-title") }}{% endblock %} +{% block eyebrow %}{{ nav.tr("transfer-eyebrow") }}{% endblock %} +{% block heading %}{{ nav.tr("import-title") }}{% endblock %} +{% block lead %}

{{ nav.tr("import-lead") }}

{% endblock %} {% block form %}
-
{% for profile in page.catalog.profiles %}{% endfor %} @@ -28,11 +28,11 @@ {%- for profile in page.catalog.profiles %}{% for table in profile.tables %}{% endfor %}{% endfor -%}
- {%- if let Some(message) = page.error %}{% call alert::error("Could not import CSV", message) %}{% endcall %}{% endif -%} + {%- if let Some(message) = page.error %}{% call alert::error(nav.locale, nav.tr("import-error-title"), message) %}{% endcall %}{% endif -%}
- Cancel - + {{ nav.tr("common-cancel") }} +
{% endblock %} diff --git a/web/templates/pages/login/login.html b/web/templates/pages/login/login.html index beffd383..ac4d5152 100644 --- a/web/templates/pages/login/login.html +++ b/web/templates/pages/login/login.html @@ -1,17 +1,17 @@ {# GET /login — crate::pages::login::ui::LoginPage #} {% extends "ui/base.html" %} -{% block title %}Sign in{% endblock %} +{% block title %}{{ nav.tr("login-title") }}{% endblock %} {% block content %}
-

Sign in

- - - - +

{{ nav.tr("login-title") }}

+ + + +
diff --git a/web/templates/pages/login/password.html b/web/templates/pages/login/password.html index b06a5f26..7242a02b 100644 --- a/web/templates/pages/login/password.html +++ b/web/templates/pages/login/password.html @@ -1,16 +1,16 @@ {% extends "ui/base.html" %} -{% block title %}Change password{% endblock %} +{% block title %}{{ nav.tr("password-title") }}{% endblock %} {% block content %}
-

Change password

-

For a freshly seeded admin or superadmin account, leave the current password empty.

- - - - +

{{ nav.tr("password-title") }}

+

{{ nav.tr("password-empty-current-hint") }}

+ + + +
diff --git a/web/templates/pages/permissions/grants/grants.html b/web/templates/pages/permissions/grants/grants.html index de758a3f..2f41ca32 100644 --- a/web/templates/pages/permissions/grants/grants.html +++ b/web/templates/pages/permissions/grants/grants.html @@ -10,7 +10,7 @@ #} {% extends "ui/base.html" %} -{% block title %}Access{% endblock %} +{% block title %}{{ nav.tr("perm-tab-access") }}{% endblock %} {% macro apply(role, mode, pairs, label, style, hint) %}
@@ -25,66 +25,66 @@
-

Permissions

-

Access

-

What a role may do with the data. Everyone holding the role gets exactly this.

+

{{ nav.tr("perm-eyebrow") }}

+

{{ nav.tr("perm-tab-access") }}

+

{{ nav.tr("grants-description") }}

{% include "pages/permissions/tabs.html" %} - {% if page.updated %}

Access updated. It applies to the next request every holder of the role makes.

{% endif %} + {% if page.updated %}

{{ nav.tr("grants-updated-notice") }}

{% endif %} {% if page.roles.is_empty() %}
-

No role to edit

-

There is no role here you outrank. Create one first.

+

{{ nav.tr("grants-no-role") }}

+

{{ nav.tr("grants-no-role-hint")|safe }}

{% else %}
-

Role

+

{{ nav.tr("grants-role-heading") }}

-
{% for group in page.groups %}
-

{% if group.global %}Everything, everywhere{% else %}Profile: {{ group.title }}{% endif %}{{ group.rows.len() }}

+

{% if group.global %}{{ nav.tr("grants-everything-everywhere") }}{% else %}{{ nav.tr("grants-profile-prefix") }} {{ group.title }}{% endif %}{{ group.rows.len() }}

{% if group.has_wildcards() %} - {% call apply(page.selected_role, "grant", group.read_pairs(), "Read only", "secondary", "Read on everything in here") %}{% endcall %} - {% call apply(page.selected_role, "grant", group.all_pairs(), "Full access", "secondary", "Every action the role may hold, on everything in here") %}{% endcall %} + {% call apply(page.selected_role, "grant", group.read_pairs(), nav.tr("grants-read-only"), "secondary", nav.tr("grants-read-only-hint")) %}{% endcall %} + {% call apply(page.selected_role, "grant", group.all_pairs(), nav.tr("grants-full-access"), "secondary", nav.tr("grants-full-access-hint")) %}{% endcall %} {% endif %} {% if group.has_direct() %} - {% call apply(page.selected_role, "revoke", group.direct_pairs(), "Clear", "danger", "Revoke everything this role holds directly in here") %}{% endcall %} + {% call apply(page.selected_role, "revoke", group.direct_pairs(), nav.tr("grants-clear"), "danger", nav.tr("grants-clear-hint")) %}{% endcall %} {% endif %}
@@ -92,9 +92,9 @@
TableWasIsWhen
{{ nav.tr("td-col-table") }}{{ nav.tr("td-col-was") }}{{ nav.tr("td-col-is") }}{{ nav.tr("td-col-when") }}
- + {% for action in page.actions() %}{% endfor %} - + @@ -108,21 +108,21 @@ {% for cell in row.cells %} {% endfor %} diff --git a/web/templates/pages/permissions/roles/roles.html b/web/templates/pages/permissions/roles/roles.html index c89d5273..d98482ea 100644 --- a/web/templates/pages/permissions/roles/roles.html +++ b/web/templates/pages/permissions/roles/roles.html @@ -1,75 +1,71 @@ {# GET /permissions/roles — crate::pages::permissions::roles::ui::RolesTemplate #} {% extends "ui/base.html" %} -{% block title %}Roles{% endblock %} +{% block title %}{{ nav.tr("perm-tab-roles") }}{% endblock %} {% block content %}
-

Permissions

-

Roles

-

A role is a named set of permissions. People are given roles; roles are given access.

+

{{ nav.tr("perm-eyebrow") }}

+

{{ nav.tr("perm-tab-roles") }}

+

{{ nav.tr("roles-description") }}

{% include "pages/permissions/tabs.html" %} - {% if page.updated %}

Roles updated. Signed-in sessions stay valid and pick up the change on their next request.

{% endif %} + {% if page.updated %}

{{ nav.tr("roles-updated-notice") }}

{% endif %}
-

New role

-

- Inheriting from another role starts the new one with everything that role has, and keeps it in step - as that role changes. Starter access is a shortcut for the two common cases; anything narrower is a - few clicks on the Access tab. -

+

{{ nav.tr("roles-new-heading") }}

+

{{ nav.tr("roles-new-hint") }}

- -
-

Existing roles{{ page.roles.len() }}

+

{{ nav.tr("roles-existing") }}{{ page.roles.len() }}

Object{{ nav.tr("grants-th-object") }}{{ action }}Row{{ nav.tr("grants-th-row") }}
{% if !cell.allowed %} - · + · {% else if cell.direct %} - {% call apply(page.selected_role, "revoke", row.one_pair(cell.action.as_str()), "✓", "cell-on", "Held directly — click to revoke") %}{% endcall %} + {% call apply(page.selected_role, "revoke", row.one_pair(cell.action.as_str()), "✓", "cell-on", nav.tr("grants-cell-direct")) %}{% endcall %} {% else if cell.inherited %} - + {% else %} - {% call apply(page.selected_role, "grant", row.one_pair(cell.action.as_str()), "+", "cell-off", "Click to grant") %}{% endcall %} + {% call apply(page.selected_role, "grant", row.one_pair(cell.action.as_str()), "+", "cell-off", nav.tr("grants-cell-grant")) %}{% endcall %} {% endif %}
- {% call apply(page.selected_role, "grant", row.all_pairs(), "All", "secondary", "Grant every action available on this object") %}{% endcall %} + {% call apply(page.selected_role, "grant", row.all_pairs(), nav.tr("grants-all"), "secondary", nav.tr("grants-all-hint")) %}{% endcall %} {% if row.has_direct() %} - {% call apply(page.selected_role, "revoke", row.direct_pairs(), "None", "danger", "Revoke this object's direct grants") %}{% endcall %} + {% call apply(page.selected_role, "revoke", row.direct_pairs(), nav.tr("grants-none"), "danger", nav.tr("grants-none-hint")) %}{% endcall %} {% endif %}
- + {% for role in page.roles %} - + diff --git a/web/templates/pages/permissions/tabs.html b/web/templates/pages/permissions/tabs.html index 2b38ace4..3c2c2427 100644 --- a/web/templates/pages/permissions/tabs.html +++ b/web/templates/pages/permissions/tabs.html @@ -7,20 +7,20 @@ someone, and deciding what it may do are three different decisions, and doing them on one screen was what made the old page hard to read. #} -
RoleKindInherits fromPeopleAccessRemove
{{ nav.tr("roles-th-role") }}{{ nav.tr("roles-th-kind") }}{{ nav.tr("roles-th-inherits") }}{{ nav.tr("roles-th-people") }}{{ nav.tr("roles-th-access") }}{{ nav.tr("roles-th-remove") }}
{{ role.name }}{% if role.built_in %} built in{% endif %}{{ role.name }}{% if role.built_in %} {{ nav.tr("roles-built-in") }}{% endif %} {{ role.kind }} {% if role.parent.is_empty() %}{% else %}{{ role.parent }}{% endif %} {% match role.users %}{% when Some with (count) %}{{ count }}{% when None %}{% endmatch %} {% if role.editable %} - Edit access + {{ nav.tr("roles-edit-access") }} {% else %} - outranks you + {{ nav.tr("perm-role-reason-outranks-you") }} {% endif %} {% if role.removable() %}
- +
{% else %} - {{ role.keeps_reason() }} + {{ role.keeps_reason(nav.locale) }} {% endif %}
- + {% for user in page.users %} @@ -38,19 +36,19 @@ - + {% else %} - + {% endif %} {% endfor %} diff --git a/web/templates/pages/register/register.html b/web/templates/pages/register/register.html index 0a7c47c3..43928011 100644 --- a/web/templates/pages/register/register.html +++ b/web/templates/pages/register/register.html @@ -1,17 +1,17 @@ {# GET /register — crate::pages::register::ui::RegisterPage #} {% extends "ui/base.html" %} -{% block title %}Register{% endblock %} +{% block title %}{{ nav.tr("register-title") }}{% endblock %} {% block content %}
-

Register

- - - - +

{{ nav.tr("register-title") }}

+ + + + {# The two fields the client offers suggestions for. A datalist keeps the lists open — the backend, not this form, decides what is accepted — while @@ -20,16 +20,16 @@ There is no role field: everyone registers as `guest` and an admin assigns a real role afterwards. #} - + {% for timezone in timezones %}{% endfor %} - + {% for country in phone_countries %}{% endfor %} - - + +
diff --git a/web/templates/ui/alert.html b/web/templates/ui/alert.html index 235d0466..277a7447 100644 --- a/web/templates/ui/alert.html +++ b/web/templates/ui/alert.html @@ -21,7 +21,7 @@ An error is normally paired with ui/dialog.html, which is what actually puts the message in front of someone who has scrolled away from the top of a form. #} -{% macro error(title, message) %} +{% macro error(locale, title, message) %} - + diff --git a/web/templates/ui/error.html b/web/templates/ui/error.html index d2e9bbea..8f3be17d 100644 --- a/web/templates/ui/error.html +++ b/web/templates/ui/error.html @@ -8,7 +8,7 @@ {% endblock %} diff --git a/web/templates/ui/form_page.html b/web/templates/ui/form_page.html index 841c3f1e..29ea309c 100644 --- a/web/templates/ui/form_page.html +++ b/web/templates/ui/form_page.html @@ -6,7 +6,7 @@ {% block content %}
- ← Admin panel + {{ nav.tr("ui-back-to-admin-panel") }}

{% block eyebrow %}{% endblock %}

{% block heading %}{% endblock %}

diff --git a/web/templates/ui/navbar.html b/web/templates/ui/navbar.html index bd1494a0..7f46f9ed 100644 --- a/web/templates/ui/navbar.html +++ b/web/templates/ui/navbar.html @@ -14,7 +14,7 @@ This replaced the hand-written dark `.topbar` bar, so the site chrome is now the library's: white, bordered underneath, with a hamburger menu below `md`. #} -
UserEmailRoleChange roleReset password
{{ nav.tr("users-th-user") }}{{ nav.tr("users-th-email") }}{{ nav.tr("users-th-role") }}{{ nav.tr("users-th-change") }}{{ nav.tr("users-th-reset") }}
- - - + + +
outranks you{{ nav.tr("perm-role-reason-outranks-you") }}