diff --git a/internal/i18n/untranslated_test.go b/internal/i18n/untranslated_test.go index 6622c26..268f15d 100644 --- a/internal/i18n/untranslated_test.go +++ b/internal/i18n/untranslated_test.go @@ -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 — diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 0daef78..830c92e 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -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.', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 7dd3e94..2475d74 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -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.', diff --git a/web/src/routes/indstillinger/integrationer/+page.svelte b/web/src/routes/indstillinger/integrationer/+page.svelte index 6f02e9c..70e4d47 100644 --- a/web/src/routes/indstillinger/integrationer/+page.svelte +++ b/web/src/routes/indstillinger/integrationer/+page.svelte @@ -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));