Skip to content

feat(web): add Norwegian and Swedish translations (#2985) - #2994

Open
vavallee wants to merge 5 commits into
mainfrom
feat/2985-norwegian-swedish-locales
Open

vavallee wants to merge 5 commits into
mainfrom
feat/2985-norwegian-swedish-locales

Conversation

@vavallee

@vavallee vavallee commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds Norwegian Bokmål (nb) and Swedish (sv) UI translations, listed in the language picker as "Norsk bokmål" and "Svenska". Both cover 1764 of 1765 en.json keys. Closes #2985.

These translations are machine assisted and need native review. francisrath offered to proofread the Norwegian; a Swedish reader would be welcome too. The glossary below is the fastest place to start: if a term is wrong, it is wrong everywhere, and fixing it is a find and replace.

Glossary

Same term everywhere it appears. Sentence case throughout, "du" form, «» quotes in nb and ”” in sv.

English Norsk bokmål Svenska Why
book bok / bøker bok / böcker
author forfatter författare
series serie serie
edition utgave utgåva
release (indexer result) utgivelse release sv: utgåva is already edition, and "release" is the usual Swedish tech loanword
Wanted Ønsket Önskade (page), Önskad (status) sv needs a plural for the page and singular for one book
grab hent / hentet hämta / hämtad short *arr verb; "last ned" / "ladda ner" is kept for the client actually downloading
indexer indekserer indexerare agent noun as Radarr/Sonarr use; nb "indekser" would read as plural of "indeks"
download client nedlastingsklient nedladdningsklient
root folder rotmappe rotmapp
library bibliotek bibliotek
import importer / import importera / import
blocklist blokkeringsliste (verb blokker) blockeringslista (verb blockera)
queue kø kö
quality profile kvalitetsprofil kvalitetsprofil
metadata profile metadataprofil metadataprofil
monitor / monitored overvåk / overvåket bevaka / bevakad established *arr terms in both
adopt (link files on disk) ta i bruk koppla judgement call, no established term
rebind koble om länka om sv avoids "koppla", which is adopt
path remap stiomkobling sökvägsmappning
API key API-nøkkel API-nyckel translated rather than left in English mid sentence

Implementation notes

  • Detection. No region mapping existed before: i18next tries de-DE then de, so nb-NO, sv-SE and sv-FI already resolve. The exceptions are no (the macrolanguage tag some browsers send) and nn (Nynorsk, no bundle of its own). fallbackLng is now an object sending no* and nn* to nb before en. Nynorsk readers read Bokmål routinely, so that beats English; the detected tag is kept, so dates still format in the reader's own variant.
  • One key left out on purpose: settings.clients.audiobookCategoryPlaceholder ("audiobooks") is an example category value for the download client, not prose, so it falls back to English.
  • Not translatable from the bundles, left alone: the Series page heading is hardcoded "Series" in SeriesPage.tsx, Calendar month names are hardcoded English, and the "Push all to Calibre" button text in CalibreTab.tsx is not in en.json. Worth a follow-up so every locale benefits.
  • English ellipsis is inconsistent: en.json mixes the ellipsis character (66 strings) with three dots (23). nb and sv now use the ellipsis character throughout, per review; en.json is left as is here and is worth evening out in a follow-up.
  • README language list updated.

Strings worth a native eye

  • queue.status.importHeld ("Held for Pair"): long for a badge.
  • discover.rows.serendipity: loose rewording.
  • settings.general.dropFolder: "Leveringsmappe" / "Leveransmapp" coined.
  • settings.oidc.fieldScopes: translated as Omfang / Omfång, though IdP screens often say "Scopes".
  • settings.import.goodreadsShelf*: should match what Goodreads shows in nb/sv.
  • settings.general.searchIntervalHint: English says "Reduce it" when hitting rate limits, but it means search less often; translated by meaning. The English may want the same fix.

Checklist

  • Tests added or updated
  • Doc-update gate cleared (README language list)
  • Wiki pages updated if user-facing behaviour changed (none list UI languages)

Test plan

  • cd web && npm ci && npm run lint (0 errors; 7 warnings, all in files this PR does not touch)
  • npm run typecheck
  • npx vitest run src/i18n (41 tests): nb/sv keys exist in en, placeholders, naming tokens and code spans match, plurals paired, no English filler; browser tags nb, nb-NO, no, no-NO, nn, nn-NO, sv, sv-SE, sv-FI resolve to the right bundle (plus de-DE and da-DK unchanged); Calendar, Wanted and Series render translated with no raw keys in both locales
  • Checked the new tests fail when broken: English filler, a renamed placeholder, fallbackLng reverted to 'en', and pages left in English each turn them red
  • npm run build
  • npm test (107 files, 1247 tests)

🤖 Generated with Claude Code

https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9

Adds nb.json and sv.json covering every en.json key except one literal
placeholder (the audiobook category example, which should stay the
value a download client expects). Both are listed in the language
picker by their native names.

Region tags already resolve through i18next's own hierarchy (nb-NO,
sv-SE and sv-FI need nothing). The no and nn tags do not, so
fallbackLng now sends them to nb before en; Nynorsk readers read
Bokmal routinely and there is no Nynorsk bundle.

Tests check that every nb and sv key exists in en.json, that
placeholders, naming tokens and code spans survive, that plurals are
paired and that no value is untranslated English; that the detector
maps each Norwegian and Swedish browser tag to the right bundle; and
that three pages render translated with no raw keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
@vavallee

vavallee commented Oct 4, 2026

Copy link
Copy Markdown
Owner Author

@francisrath here's the Norwegian PR you offered to proofread on #2985. No rush, and thanks a lot!

The Bokmål is in web/src/i18n/locales/nb.json, machine assisted, so context is exactly where it will slip. The glossary at the top of the PR description lists the term choices, which is probably the fastest place to start. A few strings worth a look:

  • queue.status.importHeld (may be too long for a badge)
  • discover.rows.serendipity (loosely reworded)
  • settings.general.dropFolder (Leveringsmappe is a made up term)
  • settings.oidc.fieldScopes (Omfang, or keep "Scopes"?)
  • settings.import.goodreadsShelf* (should match the shelf names Goodreads shows in Norwegian)

Suggestions right in the review are perfect. I won't merge until you've had a look.

@vavallee
vavallee marked this pull request as ready for review October 4, 2026 19:55

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean. A few observations:

fallbackLng object form (web/src/i18n/index.ts:23-27): backward-compatible — all existing locales not in the map (fr, de, ko, etc.) hit default: ['en'], which is the same as the old 'en' string. The only net change is no/nn routing to nb first. i18next's region-stripping means no-NO → no and nn-NO → nn before the map lookup, so neither needs its own key; the detection tests at nordicLocales.test.ts:107 confirm the full set of 11 browser tags.

Test coverage is thorough: structural checks (unknown keys, placeholder/token/code-span parity, plural pairing, English-filler guard) + render tests for Calendar/Wanted/Series in both locales + the da-DK → en regression that guards against Danish users accidentally getting Bokmål.

One missing key (settings.clients.audiobookCategoryPlaceholder) is intentional and noted in the PR body. Fine.

No blocking issues found.

— 🤖 Bindery triage bot (automated). Reply to correct me; a human will see it.

// {{interpolation}}, {NamingToken} and `code spans` must survive translation
// untouched; a renamed placeholder renders as literal braces.
const placeholders = (text: string) => [...text.matchAll(/{{\s*(\w+)\s*}}/g)].map(m => m[1]).sort()
const namingTokens = (text: string) => [...text.replace(/{{[^}]*}}/g, '').matchAll(/{[A-Za-z][\w ]*(?::[^}]*)?}/g)].map(m => m[0]).sort()
const words = text.replace(/{{[^}]*}}|{[^}]*}|`[^`]*`/g, ' ').match(/\p{L}[\p{L}\d'.-]*/gu) ?? []
return words.every(w => {
const bare = w.replace(/[.'-]+$/, '').toLowerCase()
return SHARED_WORDS.has(bare) || SHARED_BY_LOCALE[locale].has(bare)
])('resolves a browser set to %s to %s', async (tag, expected) => {
const instance = await detect([tag])
expect(instance.resolvedLanguage).toBe(expected)
const bundle = { nb, sv, en, de: instance.getResourceBundle('de', 'translation') }[expected] as Tree
@francisrath

Copy link
Copy Markdown
Contributor

@francisrath here's the Norwegian PR you offered to proofread on #2985. No rush, and thanks a lot!

The Bokmål is in web/src/i18n/locales/nb.json, machine assisted, so context is exactly where it will slip. The glossary at the top of the PR description lists the term choices, which is probably the fastest place to start. A few strings worth a look:

  • queue.status.importHeld (may be too long for a badge)
  • discover.rows.serendipity (loosely reworded)
  • settings.general.dropFolder (Leveringsmappe is a made up term)
  • settings.oidc.fieldScopes (Omfang, or keep "Scopes"?)
  • settings.import.goodreadsShelf* (should match the shelf names Goodreads shows in Norwegian)

Suggestions right in the review are perfect. I won't merge until you've had a look.

I'll have a think about it. Just skimmed the terms so far, and everything looks good down to adopt/rebind/path remap. I'll see if I can come up with better terms -- but they are already understandable. Will probably have a deeper look tomorrow!

@francisrath francisrath left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall pretty good, just a few comments below!

"saving": "Lagrer...",
"deleting": "Sletter...",
"test": "Test",
"testing": "Tester…",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use three dots instead of triple-dot to match elsewhere?

i.e. replace all "…" with "..." in the translations? (or the other way around -- as long as it's consistent)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, nb and sv now use … everywhere. en.json mixes both too, so I noted that as a follow up.

Comment thread web/src/i18n/locales/nb.json Outdated
"title": "Manuell import",
"description": "Skann en mappe med filer hvor som helst på disken, finn riktig bok i katalogen din for hver av dem, og importer dem til biblioteket. Treffene hentes bare fra katalogen, så legg til forfatteren først hvis vedkommende ikke finnes i Bindery ennå. En fil uten treff kan legges til fra et metadatasøk.",
"pathLabel": "Mappe som skal skannes",
"pathPlaceholder": "/downloads/books",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/nedlastinger/bøker

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thanks!

Comment thread web/src/i18n/locales/nb.json Outdated
"more": "Mer",
"moreFor": "Flere handlinger for {{name}}",
"match": {
"strong": "Sterkt treff",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Godt treff

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, changed here and in the matching hint sentence.

vavallee and others added 2 commits October 5, 2026 14:04
…wedish-locales

Signed-off-by: vavallee <vavallee@protonmail.com>
Uses a single ellipsis character in every nb and sv string instead of
the mix of three dots and the ellipsis that en.json has, swaps the
manual import path placeholder for a Norwegian one, and calls a strong
adoption match "Godt treff". Also translates the eight keys main added
to en.json since this branch was cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
@codecov

codecov Bot commented Oct 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

vavallee added a commit that referenced this pull request Oct 6, 2026
… interval hint (#3027)

* fix(web): translate series, calendar and push all strings, fix search interval hint

The wanted search interval is the gap between scheduled sweeps
(scheduler.go runs search-wanted "@every <interval>"), so the hint telling
rate limited users to reduce it made Bindery search more often. It now
says to choose a longer interval. The de, es, fr, nl, tl and id files
carried the same wrong advice as untranslated English, so the key is
removed there and falls back to the corrected English.

The Series page, its name modal, the Calendar month and weekday names and
the Calibre Push all button and progress modal were hardcoded English.
They now go through i18n; the Calendar formats names with
Intl.DateTimeFormat in the active language.

en.json prose now uses the ellipsis character throughout, with matching
inline defaults. The other locales (excluding nb and sv, which #2994
handles) get the same change for translated values only; English filler
values are left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>

* fix(web): drop leftover English filler and keep calendar month on one line

Removes common.deleting and discover.refreshing from locales where they
were still verbatim English, so they fall back to en.json like the
search interval hint. The calendar month label no longer wraps on phones
for long names such as Spanish "septiembre de 2026". The changelog
fragment carries the PR number and notes the capitalised series badges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>

* test(web): match CalibreTab coverage tests on i18n keys, not English

The Push all block and its modal now go through t(), and this file mocks
t to return keys, so the helper and the assertions for the description,
the bridge warning, the missing plugin URL tooltip, the done summary and
the Close button match on keys instead of English text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>

---------

Signed-off-by: vavallee <vavallee@protonmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@francisrath

Copy link
Copy Markdown
Contributor

The series work has added English strings that nb and sv don't have yet. Here they are in both, following your glossary (and «» / ”” quotes):

I've proofread the Norwegian. The Swedish looks right to me, but I'm not a native speaker, so a Swedish reader is still welcome.

These keys aren't in this branch's en.json yet, so nordicLocales.test.ts would reject them until the branch is updated from main. You're welcome to fold them in here when you update it, or merge this as it is and I'll send a follow-up PR with them afterwards. Whichever you prefer.

nb.json additions
{
  "authors": {
    "sortAZ": "Etternavn A–Å",
    "sortZA": "Etternavn Å–A"
  },
  "books": {
    "sortAuthorAZ": "Forfatter A–Å",
    "sortAuthorZA": "Forfatter Å–A"
  },
  "bookDetail": {
    "edit": {
      "fieldSeries": "Serie",
      "seriesNone": "Ingen serie",
      "seriesNew": "Ny serie…",
      "fieldNewSeries": "Navn på ny serie",
      "fieldPosition": "Nummer",
      "unlockAllHint": "La metadataoppdateringer styre alle felt og bokens serier igjen"
    },
    "series": {
      "keptOutHeading": "Holdt utenfor",
      "keptOutExplainer": "Du tok denne boken ut av disse seriene, så oppdateringer legger den ikke inn igjen. Gjenopprett setter den tilbake der den var.",
      "restore": "Gjenopprett",
      "restoreFailed": "Kunne ikke legge boken tilbake i serien"
    }
  },
  "series": {
    "merge": {
      "button": "Slå sammen…",
      "buttonHint": "Slå andre serier sammen med denne",
      "title": "Slå sammen med «{{title}}»",
      "description": "Seriene du velger, slås inn i denne og slettes. Bøkene deres beholder nummereringen i denne serien og fyller hullene i den, og ID-ene deres hos leverandøren beholdes som alias, så en oppdatering ikke oppretter dem på nytt.",
      "filterPlaceholder": "Finn en serie…",
      "sourcesLabel": "Serier som skal slås inn",
      "bookCount_one": "{{count}} bok",
      "bookCount_other": "{{count}} bøker",
      "noCandidates": "Ingen andre serier passer",
      "renameLabel": "Nytt navn (valgfritt)",
      "preview": "Forhåndsvis",
      "apply": "Slå sammen",
      "sourceSummary": "{{title}}: {{moved}} flyttes inn, {{kept}} er her fra før",
      "conflict": "{{book}} beholder #{{kept}} (den sammenslåtte serien hadde #{{dropped}})",
      "renamed": "Får nytt navn: «{{title}}»",
      "takesHardcoverLink": "Overtar Hardcover-koblingen fra «{{title}}»",
      "takesGenres": "Overtar sjangeroverstyringen fra «{{title}}»",
      "becomesMonitored": "Blir overvåket, fordi en av de sammenslåtte seriene var det",
      "aliases_one": "{{count}} gammel leverandør-ID vil peke hit",
      "aliases_other": "{{count}} gamle leverandør-ID-er vil peke hit",
      "confirmBody_one": "Slå {{count}} serie sammen med «{{title}}»? Den sammenslåtte serien slettes, og dette kan ikke angres.",
      "confirmBody_other": "Slå {{count}} serier sammen med «{{title}}»? De sammenslåtte seriene slettes, og dette kan ikke angres.",
      "failed": "Sammenslåingen mislyktes"
    }
  }
}
sv.json additions
{
  "authors": {
    "sortAZ": "Efternamn A–Ö",
    "sortZA": "Efternamn Ö–A"
  },
  "books": {
    "sortAuthorAZ": "Författare A–Ö",
    "sortAuthorZA": "Författare Ö–A"
  },
  "bookDetail": {
    "edit": {
      "fieldSeries": "Serie",
      "seriesNone": "Ingen serie",
      "seriesNew": "Ny serie…",
      "fieldNewSeries": "Namn på ny serie",
      "fieldPosition": "Nummer",
      "unlockAllHint": "Låt metadatauppdateringar hantera alla fält och bokens serier igen"
    },
    "series": {
      "keptOutHeading": "Utesluten från",
      "keptOutExplainer": "Du tog bort boken ur de här serierna, så uppdateringar lägger inte tillbaka den. Återställ sätter tillbaka den där den var.",
      "restore": "Återställ",
      "restoreFailed": "Det gick inte att lägga tillbaka boken i serien"
    }
  },
  "series": {
    "merge": {
      "button": "Slå ihop…",
      "buttonHint": "Slå ihop andra serier med den här",
      "title": "Slå ihop med ”{{title}}”",
      "description": "Serierna du väljer slås in i den här och tas bort. Deras böcker behåller numreringen i den här serien och fyller dess luckor, och deras ID:n hos leverantören behålls som alias så att en uppdatering inte skapar dem igen.",
      "filterPlaceholder": "Hitta en serie…",
      "sourcesLabel": "Serier att slå in",
      "bookCount_one": "{{count}} bok",
      "bookCount_other": "{{count}} böcker",
      "noCandidates": "Inga andra serier matchar",
      "renameLabel": "Nytt namn (valfritt)",
      "preview": "Förhandsgranska",
      "apply": "Slå ihop",
      "sourceSummary": "{{title}}: {{moved}} flyttas in, {{kept}} finns redan här",
      "conflict": "{{book}} behåller #{{kept}} (den ihopslagna serien hade #{{dropped}})",
      "renamed": "Byter namn till ”{{title}}”",
      "takesHardcoverLink": "Tar över Hardcover-länken från ”{{title}}”",
      "takesGenres": "Tar över genreåsidosättningen från ”{{title}}”",
      "becomesMonitored": "Blir bevakad, eftersom en av de ihopslagna serierna var det",
      "aliases_one": "{{count}} gammalt leverantörs-ID kommer att peka hit",
      "aliases_other": "{{count}} gamla leverantörs-ID:n kommer att peka hit",
      "confirmBody_one": "Slå ihop {{count}} serie med ”{{title}}”? Den ihopslagna serien tas bort, och det går inte att ångra.",
      "confirmBody_other": "Slå ihop {{count}} serier med ”{{title}}”? De ihopslagna serierna tas bort, och det går inte att ångra.",
      "failed": "Sammanslagningen misslyckades"
    }
  }
}

vavallee and others added 2 commits October 8, 2026 14:15
…wedish-locales

main now loads every locale but English lazily through a glob backend, so
nb and sv no longer need static imports. FALLBACK_LNG is kept and passed as
fallbackLng so no and nn still resolve to nb.

The detection tests waited on isInitialized, which stays true on the
i18next singleton across resetModules; with async locale loading that
returned before the new init settled. They now await i18nReady.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
Adds the series.merge strings from #3057 to both locales. The keys from
#3090 and #3116 wait until those land in en.json.

Translations by francisrath.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Norwegian and Swedish language support

3 participants