fix(i18n): en engelsk flade sagde "2 opgave(r) hentet"
Release / Build and publish image (push) Failing after 10m6s

Beskeden efter "Fetch now" stod på dansk uanset sprog, og med et "(r)" i stedet
for rigtig bøjning. Den bruger nu t() og plural() som resten.

Vagten burde have fanget den og gjorde det ikke: den krævede et anførselstegn
lige efter app.toast(, og denne stod som en skabelonstreng inde i en ternær hen
over to linjer. Den læser nu hele argumentlisten frem til den matchende
slutparentes, så det er formen på kaldet og ikke tilfældig placering, der afgør,
om en streng bliver set. Sat på prøve ved at lægge den gamle tekst tilbage:
begge strenge blev fanget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 15:14:37 +02:00
co-authored by Claude Opus 5
parent fe7ce5c853
commit dab1827c14
4 changed files with 51 additions and 10 deletions
+41 -8
View File
@@ -28,11 +28,13 @@ import (
// the app, which is the worst possible set to leave in one language, because it is
// the text somebody reads in the half-second before they agree to lose something.
func TestNoDanishProseInDialogsAndToasts(t *testing.T) {
// The argument of one of the three, quoted or backticked. Deliberately narrow:
// a general scan of every string literal in a Svelte script block would drown
// in class names, keys and query fragments.
call := regexp.MustCompile(`(?:confirm|alert|app\.toast)\(\s*['` + "`" + `]([^'` + "`" + `]{4,})['` + "`" + `]`)
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|opgave|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb|bliver|holder|alle)\b`)
// Every string literal in the argument list of one of the three, not just one
// sitting flush against the paren. A toast written as a ternary over two lines
// hid Danish from the earlier version of this guard for a whole release.
// Deliberately still narrow: a general scan of every literal in a script block
// would drown in class names, keys and query fragments.
call := regexp.MustCompile(`(?:confirm|alert|app\.toast)\(`)
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|gemmes|hentet|hentes|beskeder|opgave|opgaver|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb|bliver|holder|alle)\b`)
var found []string
root := filepath.Join("..", "..", "web", "src")
@@ -51,9 +53,12 @@ func TestNoDanishProseInDialogsAndToasts(t *testing.T) {
return err
}
rel, _ := filepath.Rel(root, path)
for _, m := range call.FindAllStringSubmatch(string(body), -1) {
if danish.MatchString(m[1]) {
found = append(found, rel+": "+m[1])
src := string(body)
for _, loc := range call.FindAllStringIndex(src, -1) {
for _, lit := range literalsInCall(src[loc[1]:]) {
if danish.MatchString(lit) {
found = append(found, rel+": "+lit)
}
}
}
return nil
@@ -70,6 +75,34 @@ func TestNoDanishProseInDialogsAndToasts(t *testing.T) {
}
}
// literalsInCall returns the string literals in one argument list, given the text
// just past its opening paren. It stops at the matching close paren so the next
// statement's strings are somebody else's problem.
func literalsInCall(src string) []string {
var out []string
depth := 1
for i := 0; i < len(src); i++ {
switch c := src[i]; c {
case '(':
depth++
case ')':
if depth--; depth == 0 {
return out
}
case '\'', '"', '`':
end := strings.IndexByte(src[i+1:], c)
if end < 0 {
return out
}
if lit := src[i+1 : i+1+end]; len(lit) >= 4 {
out = append(out, lit)
}
i += end + 1
}
}
return out
}
func TestNoDanishProseOutsideTheDictionaries(t *testing.T) {
// Text nodes of three characters or more, and the three attributes a person
// reads. Anything inside {…} is already an expression — usually a t() call —
+3
View File
@@ -497,6 +497,9 @@ export const da = {
'int.server': 'Server',
'int.caldavAuth':
'Brugernavn er din e-mail. Adgangskoden er en API-token — ikke din rigtige adgangskode.',
'int.gmailFetchedOne': '{n} opgave hentet.',
'int.gmailFetchedMany': '{n} opgaver hentet.',
'int.gmailNothingNew': 'Ingen nye beskeder.',
'int.gmailConnected': 'Gmail er forbundet.',
'int.gmailState':
'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.',
+3
View File
@@ -493,6 +493,9 @@ export const en = {
'int.server': 'Server',
'int.caldavAuth':
'The username is your email. The password is an API token — not your real password.',
'int.gmailFetchedOne': '{n} task fetched.',
'int.gmailFetchedMany': '{n} tasks fetched.',
'int.gmailNothingNew': 'No new messages.',
'int.gmailConnected': 'Gmail is connected.',
'int.gmailState': 'Google’s reply did not match the attempt that started here. Try again.',
'int.gmailExpired': 'The attempt expired on the way. Try again.',
@@ -3,7 +3,7 @@
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { page } from '$app/stores';
import { t } from '$lib/i18n.svelte.js';
import { t, plural } from '$lib/i18n.svelte.js';
// --- Gmail ------------------------------------------------------------------
@@ -115,7 +115,9 @@
try {
const result = await api.syncGmail();
app.toast(
result?.created ? `${result.created} opgave(r) hentet.` : 'Ingen nye beskeder.'
result?.created
? plural(result.created, 'int.gmailFetchedOne', 'int.gmailFetchedMany')
: t('int.gmailNothingNew')
);
} catch (e) {
app.toast(humanMessage(e));