summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/cli.go3
-rw-r--r--internal/config/config.go15
-rw-r--r--internal/i18n/gen_test.go69
-rw-r--r--internal/i18n/golden_test.go191
-rw-r--r--internal/i18n/i18n.go195
-rw-r--r--internal/i18n/lang.go155
-rw-r--r--internal/i18n/lang/en.ini79
-rw-r--r--internal/i18n/lang/pl.ini79
-rw-r--r--internal/i18n/override_test.go28
9 files changed, 627 insertions, 187 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 1fc7ac0..16ba753 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -207,6 +207,9 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if dir, err := config.NamesDir(); err == nil {
naming.SetUserDir(dir)
}
+ if dir, err := config.UIDir(); err == nil {
+ i18n.SetUserDir(dir)
+ }
if lectionary != "" {
cfg.Lectionary = lectionary
}
diff --git a/internal/config/config.go b/internal/config/config.go
index c9a00f2..08c0b60 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -37,7 +37,7 @@ const configHeader = `# lectio configuration (INI). Full-line comments only (# o
# width integer; 0 = detect terminal width
# all true | false (all readings, or just the gospel)
# offline true | false (never fetch; cache/sigla only)
-# ui_language any code (en, pl, ...) (chrome is en/pl, else English; day/saint names from names/<code>.ini)
+# ui_language any code (en, pl, ...) (chrome from ui/<code>.ini, day names from names/<code>.ini; en/pl built in, else English)
# reading_lang language for offline reading text (e.g. en, pl); blank follows ui_language
# reading_version force a specific bible corpus code (e.g. drb, wuj); blank = auto-resolve
# sigla_style auto | polish | english | latin (citation dialect; auto follows ui_language)
@@ -347,6 +347,19 @@ func CorporaDir() (string, error) {
return filepath.Join(filepath.Dir(p), "corpora"), nil
}
+// UIDir is the user directory for drop-in UI-chrome language files
+// (<code>.ini: button/label/keybar strings), alongside names/, calendars/ and
+// corpora/ under the lectio config dir. A file here overrides the built-in
+// chrome for its language key by key; with ui_language = <code> it translates
+// the whole interface without a rebuild. Mirrors NamesDir.
+func UIDir() (string, error) {
+ p, _, err := configPath()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(filepath.Dir(p), "ui"), nil
+}
+
// NamesDir is the user directory for drop-in day-name language files
// (<code>.ini), alongside calendars/ and corpora/ under the lectio config dir.
// A file here overrides the built-in day names for its language key by key;
diff --git a/internal/i18n/gen_test.go b/internal/i18n/gen_test.go
new file mode 100644
index 0000000..82dfd83
--- /dev/null
+++ b/internal/i18n/gen_test.go
@@ -0,0 +1,69 @@
+package i18n
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "sort"
+ "strings"
+ "testing"
+)
+
+// quote wraps a value in double quotes when it has meaningful leading/trailing
+// whitespace, so the INI reader's trim does not eat it.
+func quote(s string) string {
+ if s != strings.TrimSpace(s) {
+ return `"` + s + `"`
+ }
+ return s
+}
+
+// marshalUI renders a UI table as an INI file, the inverse of applyUI. It is the
+// generator for the embedded lang/<code>.ini files; the mapping is by reflection
+// so adding a UI field needs no generator change.
+func marshalUI(u UI) []byte {
+ var b strings.Builder
+ v := reflect.ValueOf(u)
+ t := v.Type()
+ for i := 0; i < t.NumField(); i++ {
+ key := iniKey(t.Field(i).Name)
+ fv := v.Field(i)
+ switch fv.Kind() {
+ case reflect.String:
+ b.WriteString(key + " = " + quote(fv.String()) + "\n")
+ case reflect.Map:
+ ks := make([]string, 0, fv.Len())
+ for _, k := range fv.MapKeys() {
+ ks = append(ks, k.String())
+ }
+ sort.Strings(ks)
+ for _, mk := range ks {
+ b.WriteString(key + "." + mk + " = " + quote(fv.MapIndex(reflect.ValueOf(mk)).String()) + "\n")
+ }
+ case reflect.Array:
+ parts := make([]string, fv.Len())
+ for j := 0; j < fv.Len(); j++ {
+ parts[j] = fv.Index(j).String()
+ }
+ b.WriteString(key + " = " + strings.Join(parts, ",") + "\n")
+ }
+ }
+ return []byte(b.String())
+}
+
+// TestGenerateLangFiles (re)writes the embedded English and Polish chrome files
+// from the Go golden tables. It is a generator, not an assertion: run it with
+// LECTIO_GEN=1 after changing enUI/plUI to refresh lang/en.ini and lang/pl.ini.
+func TestGenerateLangFiles(t *testing.T) {
+ if os.Getenv("LECTIO_GEN") == "" {
+ t.Skip("set LECTIO_GEN=1 to regenerate lang/*.ini from the Go tables")
+ }
+ if err := os.MkdirAll("lang", 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for code, u := range map[string]UI{"en": enUI, "pl": plUI} {
+ if err := os.WriteFile(filepath.Join("lang", code+".ini"), marshalUI(u), 0o644); err != nil {
+ t.Fatalf("write %s: %v", code, err)
+ }
+ }
+}
diff --git a/internal/i18n/golden_test.go b/internal/i18n/golden_test.go
new file mode 100644
index 0000000..e6dc00a
--- /dev/null
+++ b/internal/i18n/golden_test.go
@@ -0,0 +1,191 @@
+package i18n
+
+import (
+ "reflect"
+ "testing"
+)
+
+// enUI and plUI are the golden chrome tables. They are the SOURCE the embedded
+// lang/en.ini and lang/pl.ini are generated from (TestGenerateLangFiles), and
+// TestGoldenMatchesEmbedded asserts the embedded files still parse back to them
+// -- so a hand-edit to a lang file that drifts from these is caught.
+var enUI = UI{
+ Version: map[string]string{
+ "wuj": "Wujek (Polish)",
+ "vul": "Vulgate (Latin)",
+ "grb": "Greek",
+ "drb": "Douay-Rheims (English)",
+ },
+ PartLabel: map[string]string{
+ "pierwsze_czytanie": "1st reading",
+ "psalm": "Psalm",
+ "drugie_czytanie": "2nd reading",
+ "aklamacja": "Acclamation",
+ "ewangelia": "Gospel",
+ },
+ FooterKeys: "tab/⇧tab version ←/→ day d date j/k scroll space/b page g/G top/bottom q quit",
+ Loading: "loading…",
+ NoReadingsFor: "no readings for ",
+ ErrorPrefix: "error: ",
+ ErrorHint: "change date (←/→)",
+ JumpPrompt: "go to date (YYYY-MM-DD)",
+ ReaderTitle: "reader — pick a book",
+ ReaderPickKeys: "type to filter ↑/↓ move enter open esc quit",
+ ReaderReadKeys: "n/p chapter c go-to tab version j/k scroll m mark b bookmarks esc books q quit",
+ ReaderNoText: "(no text in this version)",
+ ReaderNoMatch: "(no matching books)",
+ ReaderMarkPrompt: "note (enter to save, esc to cancel)",
+ ReaderBookmarksTitle: "bookmarks",
+ ReaderBookmarksKeys: "j/k move enter open d delete esc back q quit",
+ ReaderNoBookmarks: "no bookmarks yet — press m while reading",
+ ReaderMarkVerseKeys: "↑/↓ pick verse enter add note esc cancel",
+ ReaderConfirmDelete: "Delete this bookmark?",
+ ReaderConfirmKeys: "y = delete n = cancel",
+ ReaderMarkNote: "note",
+ ReaderMarkTags: "tags",
+ ReaderMarkHelp: "enter save · tab switch field · esc cancel",
+ ReaderJumpChapter: "go to chapter",
+ ReaderJumpChapterKeys: "enter go · esc cancel",
+ BannerGospel: "Gospel",
+ BannerReadings: "Readings",
+ BannerConnective: "for",
+ Months: [12]string{"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"},
+ Lectionary: "lectionary",
+ OptModern: "modern",
+ OptTraditional: "traditional",
+ Parts: "parts",
+ OptGospel: "Gospel",
+ OptAll: "all parts",
+ Layout: "layout",
+ OptHorizontal: "horizontal",
+ OptColumns: "columns",
+ OptInterlinear: "interlinear",
+ Theme: "theme",
+ Mono: "mono",
+ NoInterlinearVerses: "(no verses to align)",
+ NavReader: "reader",
+ WebBook: "book",
+ WebChapter: "chapter",
+ NavHome: "home",
+ NavSettings: "settings",
+ NavBookmarks: "bookmarks",
+ WebVerseOptional: "verse (optional)",
+ WebTagsPlaceholder: "tags, comma-separated",
+ WebAddBookmark: "bookmark",
+ WebAllTags: "all",
+ WebNoBookmarks: "No bookmarks yet.",
+ WebDelete: "delete",
+ WebSaved: "Saved.",
+ WebUILang: "UI language",
+ WebSiglaStyle: "sigla style",
+ WebDefaultVersion: "default version",
+ WebVersions: "versions",
+ WebWebVersions: "web versions",
+ WebOffline: "offline",
+ WebWidth: "width",
+ WebPort: "web port",
+ WebPager: "pager",
+ WebSave: "Save",
+ Export: "download",
+ Calendar: "month calendar",
+ NoReadingsDay: "no readings for this day",
+ NoVersion: "(not in %s)",
+ NoVersionPartial: "(not in %s: %s)",
+ NoReference: "(no reference)",
+ NoReferenceErr: "(no reference: %v)",
+}
+
+var plUI = UI{
+ Version: map[string]string{
+ "wuj": "Wujek (pol.)",
+ "vul": "Wulgata (lac.)",
+ "grb": "Grecki",
+ "drb": "Douay-Rheims (ang.)",
+ },
+ PartLabel: map[string]string{
+ "pierwsze_czytanie": "1. czytanie",
+ "psalm": "Psalm",
+ "drugie_czytanie": "2. czytanie",
+ "aklamacja": "Aklamacja",
+ "ewangelia": "Ewangelia",
+ },
+ FooterKeys: "tab/⇧tab wersja ←/→ dzień d data j/k przewiń spacja/b strona g/G góra/dół q wyjście",
+ Loading: "ładowanie…",
+ NoReadingsFor: "brak czytań na ",
+ ErrorPrefix: "błąd: ",
+ ErrorHint: "zmień datę (←/→)",
+ JumpPrompt: "przejdź do daty (RRRR-MM-DD)",
+ ReaderTitle: "czytnik — wybierz księgę",
+ ReaderPickKeys: "wpisz, by filtrować ↑/↓ ruch enter otwórz esc wyjście",
+ ReaderReadKeys: "n/p rozdział c skok tab wersja j/k przewiń m zakładka b zakładki esc księgi q wyjście",
+ ReaderNoText: "(brak tekstu w tej wersji)",
+ ReaderNoMatch: "(brak pasujących ksiąg)",
+ ReaderMarkPrompt: "notatka (enter zapisz, esc anuluj)",
+ ReaderBookmarksTitle: "zakładki",
+ ReaderBookmarksKeys: "j/k ruch enter otwórz d usuń esc wróć q wyjście",
+ ReaderNoBookmarks: "brak zakładek — naciśnij m podczas czytania",
+ ReaderMarkVerseKeys: "↑/↓ wybierz werset enter notatka esc anuluj",
+ ReaderConfirmDelete: "Usunąć zakładkę?",
+ ReaderConfirmKeys: "y = usuń n = anuluj",
+ ReaderMarkNote: "notatka",
+ ReaderMarkTags: "tagi",
+ ReaderMarkHelp: "enter zapisz · tab pole · esc anuluj",
+ ReaderJumpChapter: "przejdź do rozdziału",
+ ReaderJumpChapterKeys: "enter przejdź · esc anuluj",
+ BannerGospel: "Ewangelia",
+ BannerReadings: "Czytania",
+ BannerConnective: "na",
+ Months: [12]string{"Styczeń", "Luty", "Marzec", "Kwiecień", "Maj", "Czerwiec", "Lipiec", "Sierpień", "Wrzesień", "Październik", "Listopad", "Grudzień"},
+ Lectionary: "lekcjonarz",
+ OptModern: "nowy",
+ OptTraditional: "tradycyjny",
+ Parts: "zakres",
+ OptGospel: "Ewangelia",
+ OptAll: "wszystkie części",
+ Layout: "układ",
+ OptHorizontal: "poziomo",
+ OptColumns: "kolumny",
+ OptInterlinear: "interlinearnie",
+ Theme: "motyw",
+ Mono: "mono",
+ NoInterlinearVerses: "(brak wersetów do zestawienia interlinearnego)",
+ NavReader: "czytnik",
+ WebBook: "księga",
+ WebChapter: "rozdział",
+ NavHome: "strona główna",
+ NavSettings: "ustawienia",
+ NavBookmarks: "zakładki",
+ WebVerseOptional: "werset (opcjonalnie)",
+ WebTagsPlaceholder: "tagi, oddzielone przecinkami",
+ WebAddBookmark: "zakładka",
+ WebAllTags: "wszystkie",
+ WebNoBookmarks: "Brak zakładek.",
+ WebDelete: "usuń",
+ WebSaved: "Zapisano.",
+ WebUILang: "język interfejsu",
+ WebSiglaStyle: "styl sigli",
+ WebDefaultVersion: "wersja domyślna",
+ WebVersions: "wersje",
+ WebWebVersions: "wersje web",
+ WebOffline: "offline",
+ WebWidth: "szerokość",
+ WebPort: "port web",
+ WebPager: "pager",
+ WebSave: "Zapisz",
+ Export: "pobierz",
+ Calendar: "kalendarz miesiąca",
+ NoReadingsDay: "brak czytań na ten dzień",
+ NoVersion: "(brak w „%s”)",
+ NoVersionPartial: "(brak w „%s”: %s)",
+ NoReference: "(brak odwołania)",
+ NoReferenceErr: "(brak odwołania: %w)",
+}
+
+func TestGoldenMatchesEmbedded(t *testing.T) {
+ if got := Get("en"); !reflect.DeepEqual(got, enUI) {
+ t.Errorf("Get(en) != golden enUI:\n got %+v\nwant %+v", got, enUI)
+ }
+ if got := Get("pl"); !reflect.DeepEqual(got, plUI) {
+ t.Errorf("Get(pl) != golden plUI:\n got %+v\nwant %+v", got, plUI)
+ }
+}
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go
index f2221b6..1589476 100644
--- a/internal/i18n/i18n.go
+++ b/internal/i18n/i18n.go
@@ -1,8 +1,12 @@
-// Package i18n holds lectio's UI-chrome string tables (English and Polish)
-// for the ui_language config setting. It localises the chrome only --
-// version-column labels, the TUI keybar/messages, the CLI banner words, the
-// web control labels, and the modern-lectionary section-heading label words
-// -- never the scripture verse text or citation, which stay source-language.
+// Package i18n holds lectio's UI-chrome strings -- the version-column labels,
+// the TUI keybar/messages, the CLI banner words, the web control labels, and the
+// modern-lectionary section-heading label words. It localises the chrome only,
+// never the scripture verse text or citation, which stay source-language.
+//
+// English is the built-in baseline and Polish ships embedded (lang/en.ini,
+// lang/pl.ini). Any language is user-overridable at <config>/ui/<code>.ini,
+// resolved per key with an English fallback (see lang.go / Get), so a user can
+// translate the whole interface for their language without a rebuild.
package i18n
// UI is one language's complete set of chrome strings.
@@ -94,184 +98,3 @@ type UI struct {
// errors.Unwrap-ability, both format identically via Error()).
NoVersion, NoVersionPartial, NoReference, NoReferenceErr string
}
-
-// Get returns lang's chrome string set, falling back to English for
-// anything other than "pl".
-func Get(lang string) UI {
- if lang == "pl" {
- return plUI
- }
- return enUI
-}
-
-var enUI = UI{
- Version: map[string]string{
- "wuj": "Wujek (Polish)",
- "vul": "Vulgate (Latin)",
- "grb": "Greek",
- "drb": "Douay-Rheims (English)",
- },
- PartLabel: map[string]string{
- "pierwsze_czytanie": "1st reading",
- "psalm": "Psalm",
- "drugie_czytanie": "2nd reading",
- "aklamacja": "Acclamation",
- "ewangelia": "Gospel",
- },
- FooterKeys: "tab/⇧tab version ←/→ day d date j/k scroll space/b page g/G top/bottom q quit",
- Loading: "loading…",
- NoReadingsFor: "no readings for ",
- ErrorPrefix: "error: ",
- ErrorHint: "change date (←/→)",
- JumpPrompt: "go to date (YYYY-MM-DD)",
- ReaderTitle: "reader — pick a book",
- ReaderPickKeys: "type to filter ↑/↓ move enter open esc quit",
- ReaderReadKeys: "n/p chapter c go-to tab version j/k scroll m mark b bookmarks esc books q quit",
- ReaderNoText: "(no text in this version)",
- ReaderNoMatch: "(no matching books)",
- ReaderMarkPrompt: "note (enter to save, esc to cancel)",
- ReaderBookmarksTitle: "bookmarks",
- ReaderBookmarksKeys: "j/k move enter open d delete esc back q quit",
- ReaderNoBookmarks: "no bookmarks yet — press m while reading",
- ReaderMarkVerseKeys: "↑/↓ pick verse enter add note esc cancel",
- ReaderConfirmDelete: "Delete this bookmark?",
- ReaderConfirmKeys: "y = delete n = cancel",
- ReaderMarkNote: "note",
- ReaderMarkTags: "tags",
- ReaderMarkHelp: "enter save · tab switch field · esc cancel",
- ReaderJumpChapter: "go to chapter",
- ReaderJumpChapterKeys: "enter go · esc cancel",
- BannerGospel: "Gospel",
- BannerReadings: "Readings",
- BannerConnective: "for",
- Months: [12]string{"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"},
- Lectionary: "lectionary",
- OptModern: "modern",
- OptTraditional: "traditional",
- Parts: "parts",
- OptGospel: "Gospel",
- OptAll: "all parts",
- Layout: "layout",
- OptHorizontal: "horizontal",
- OptColumns: "columns",
- OptInterlinear: "interlinear",
- Theme: "theme",
- Mono: "mono",
- NoInterlinearVerses: "(no verses to align)",
- NavReader: "reader",
- WebBook: "book",
- WebChapter: "chapter",
- NavHome: "home",
- NavSettings: "settings",
- NavBookmarks: "bookmarks",
- WebVerseOptional: "verse (optional)",
- WebTagsPlaceholder: "tags, comma-separated",
- WebAddBookmark: "bookmark",
- WebAllTags: "all",
- WebNoBookmarks: "No bookmarks yet.",
- WebDelete: "delete",
- WebSaved: "Saved.",
- WebUILang: "UI language",
- WebSiglaStyle: "sigla style",
- WebDefaultVersion: "default version",
- WebVersions: "versions",
- WebWebVersions: "web versions",
- WebOffline: "offline",
- WebWidth: "width",
- WebPort: "web port",
- WebPager: "pager",
- WebSave: "Save",
- Export: "download",
- Calendar: "month calendar",
- NoReadingsDay: "no readings for this day",
- NoVersion: "(not in %s)",
- NoVersionPartial: "(not in %s: %s)",
- NoReference: "(no reference)",
- NoReferenceErr: "(no reference: %v)",
-}
-
-var plUI = UI{
- Version: map[string]string{
- "wuj": "Wujek (pol.)",
- "vul": "Wulgata (lac.)",
- "grb": "Grecki",
- "drb": "Douay-Rheims (ang.)",
- },
- PartLabel: map[string]string{
- "pierwsze_czytanie": "1. czytanie",
- "psalm": "Psalm",
- "drugie_czytanie": "2. czytanie",
- "aklamacja": "Aklamacja",
- "ewangelia": "Ewangelia",
- },
- FooterKeys: "tab/⇧tab wersja ←/→ dzień d data j/k przewiń spacja/b strona g/G góra/dół q wyjście",
- Loading: "ładowanie…",
- NoReadingsFor: "brak czytań na ",
- ErrorPrefix: "błąd: ",
- ErrorHint: "zmień datę (←/→)",
- JumpPrompt: "przejdź do daty (RRRR-MM-DD)",
- ReaderTitle: "czytnik — wybierz księgę",
- ReaderPickKeys: "wpisz, by filtrować ↑/↓ ruch enter otwórz esc wyjście",
- ReaderReadKeys: "n/p rozdział c skok tab wersja j/k przewiń m zakładka b zakładki esc księgi q wyjście",
- ReaderNoText: "(brak tekstu w tej wersji)",
- ReaderNoMatch: "(brak pasujących ksiąg)",
- ReaderMarkPrompt: "notatka (enter zapisz, esc anuluj)",
- ReaderBookmarksTitle: "zakładki",
- ReaderBookmarksKeys: "j/k ruch enter otwórz d usuń esc wróć q wyjście",
- ReaderNoBookmarks: "brak zakładek — naciśnij m podczas czytania",
- ReaderMarkVerseKeys: "↑/↓ wybierz werset enter notatka esc anuluj",
- ReaderConfirmDelete: "Usunąć zakładkę?",
- ReaderConfirmKeys: "y = usuń n = anuluj",
- ReaderMarkNote: "notatka",
- ReaderMarkTags: "tagi",
- ReaderMarkHelp: "enter zapisz · tab pole · esc anuluj",
- ReaderJumpChapter: "przejdź do rozdziału",
- ReaderJumpChapterKeys: "enter przejdź · esc anuluj",
- BannerGospel: "Ewangelia",
- BannerReadings: "Czytania",
- BannerConnective: "na",
- Months: [12]string{"Styczeń", "Luty", "Marzec", "Kwiecień", "Maj", "Czerwiec", "Lipiec", "Sierpień", "Wrzesień", "Październik", "Listopad", "Grudzień"},
- Lectionary: "lekcjonarz",
- OptModern: "nowy",
- OptTraditional: "tradycyjny",
- Parts: "zakres",
- OptGospel: "Ewangelia",
- OptAll: "wszystkie części",
- Layout: "układ",
- OptHorizontal: "poziomo",
- OptColumns: "kolumny",
- OptInterlinear: "interlinearnie",
- Theme: "motyw",
- Mono: "mono",
- NoInterlinearVerses: "(brak wersetów do zestawienia interlinearnego)",
- NavReader: "czytnik",
- WebBook: "księga",
- WebChapter: "rozdział",
- NavHome: "strona główna",
- NavSettings: "ustawienia",
- NavBookmarks: "zakładki",
- WebVerseOptional: "werset (opcjonalnie)",
- WebTagsPlaceholder: "tagi, oddzielone przecinkami",
- WebAddBookmark: "zakładka",
- WebAllTags: "wszystkie",
- WebNoBookmarks: "Brak zakładek.",
- WebDelete: "usuń",
- WebSaved: "Zapisano.",
- WebUILang: "język interfejsu",
- WebSiglaStyle: "styl sigli",
- WebDefaultVersion: "wersja domyślna",
- WebVersions: "wersje",
- WebWebVersions: "wersje web",
- WebOffline: "offline",
- WebWidth: "szerokość",
- WebPort: "port web",
- WebPager: "pager",
- WebSave: "Zapisz",
- Export: "pobierz",
- Calendar: "kalendarz miesiąca",
- NoReadingsDay: "brak czytań na ten dzień",
- NoVersion: "(brak w „%s”)",
- NoVersionPartial: "(brak w „%s”: %s)",
- NoReference: "(brak odwołania)",
- NoReferenceErr: "(brak odwołania: %w)",
-}
diff --git a/internal/i18n/lang.go b/internal/i18n/lang.go
new file mode 100644
index 0000000..8ce429c
--- /dev/null
+++ b/internal/i18n/lang.go
@@ -0,0 +1,155 @@
+package i18n
+
+import (
+ "embed"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "sync"
+ "unicode"
+
+ "github.com/lukaszkasprzak/lectio/internal/ini"
+)
+
+//go:embed lang
+var langFS embed.FS
+
+var (
+ uiMu sync.Mutex
+ uiCache = map[string]UI{}
+ userDir string
+)
+
+// SetUserDir points i18n at the drop-in dir holding user chrome files
+// (<dir>/<code>.ini) and invalidates the cache. Empty string disables it.
+func SetUserDir(dir string) {
+ uiMu.Lock()
+ userDir = dir
+ uiCache = map[string]UI{}
+ uiMu.Unlock()
+}
+
+// Get returns lang's chrome strings: the embedded English baseline, overlaid by
+// the embedded lang/<code>.ini (Polish ships; other codes fall through), overlaid
+// by the user's <dir>/<code>.ini if present. Every lookup falls back to English
+// per key, so an unknown language or a partial translation still renders.
+func Get(lang string) UI {
+ uiMu.Lock()
+ defer uiMu.Unlock()
+ if u, ok := uiCache[lang]; ok {
+ return u
+ }
+ var u UI
+ applyEmbedded(&u, "en") // complete English baseline
+ if lang != "" && lang != "en" {
+ applyEmbedded(&u, lang)
+ }
+ if userDir != "" && lang != "" {
+ if data, err := os.ReadFile(filepath.Join(userDir, lang+".ini")); err == nil {
+ applyUI(&u, data)
+ }
+ }
+ uiCache[lang] = u
+ return u
+}
+
+// applyEmbedded overlays the embedded lang/<code>.ini onto u, if lectio ships
+// one for that code.
+func applyEmbedded(u *UI, code string) {
+ if data, err := langFS.ReadFile("lang/" + code + ".ini"); err == nil {
+ applyUI(u, data)
+ }
+}
+
+// This file makes the UI chrome data-driven: the English and Polish tables ship
+// as embedded lang/<code>.ini files (generated from the Go tables, see the
+// gen test), and any language is user-overridable at <config>/ui/<code>.ini.
+// Field <-> INI-key mapping is by reflection so the two stay in sync
+// automatically: a struct field FooterKeys is the key footer_keys; the
+// Version/PartLabel maps are dotted keys (version.wuj, part_label.ewangelia);
+// Months is a comma-joined list. Values with meaningful leading/trailing space
+// (e.g. "error: ") are double-quoted in the file so the INI reader's trim keeps
+// them.
+
+// iniKey converts a Go field name to its snake_case INI key. Acronym runs break
+// at the last capital before a lower-case letter, so WebUILang -> web_ui_lang
+// and NoVersionPartial -> no_version_partial.
+func iniKey(name string) string {
+ runes := []rune(name)
+ var b strings.Builder
+ for i, r := range runes {
+ if unicode.IsUpper(r) {
+ if i > 0 && (unicode.IsLower(runes[i-1]) ||
+ (i+1 < len(runes) && unicode.IsLower(runes[i+1]))) {
+ b.WriteByte('_')
+ }
+ b.WriteRune(unicode.ToLower(r))
+ } else {
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
+}
+
+// unquote strips a single pair of surrounding double quotes (used to preserve
+// edge whitespace through the INI reader's trimming).
+func unquote(s string) string {
+ if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
+ return s[1 : len(s)-1]
+ }
+ return s
+}
+
+// applyUI overlays the strings parsed from one language INI onto u, setting only
+// the fields the file provides -- so a partial user file overrides just those
+// keys and everything else keeps the English baseline.
+func applyUI(u *UI, data []byte) {
+ secs, err := ini.Parse(data)
+ if err != nil {
+ return
+ }
+ flat := map[string]string{}
+ nested := map[string]map[string]string{} // fieldKey -> mapKey -> value
+ for _, s := range secs {
+ for _, p := range s.Pairs {
+ if i := strings.IndexByte(p.Key, '.'); i >= 0 {
+ fk, mk := p.Key[:i], p.Key[i+1:]
+ if nested[fk] == nil {
+ nested[fk] = map[string]string{}
+ }
+ nested[fk][mk] = unquote(p.Val)
+ } else {
+ flat[p.Key] = unquote(p.Val)
+ }
+ }
+ }
+ v := reflect.ValueOf(u).Elem()
+ t := v.Type()
+ for i := 0; i < t.NumField(); i++ {
+ key := iniKey(t.Field(i).Name)
+ fv := v.Field(i)
+ switch fv.Kind() {
+ case reflect.String:
+ if val, ok := flat[key]; ok {
+ fv.SetString(val)
+ }
+ case reflect.Map:
+ if m, ok := nested[key]; ok {
+ if fv.IsNil() {
+ fv.Set(reflect.MakeMap(fv.Type()))
+ }
+ for mk, mv := range m {
+ fv.SetMapIndex(reflect.ValueOf(mk), reflect.ValueOf(mv))
+ }
+ }
+ case reflect.Array: // Months [12]string
+ if val, ok := flat[key]; ok {
+ parts := strings.Split(val, ",")
+ for j := 0; j < fv.Len() && j < len(parts); j++ {
+ fv.Index(j).SetString(strings.TrimSpace(parts[j]))
+ }
+ }
+ }
+ }
+}
diff --git a/internal/i18n/lang/en.ini b/internal/i18n/lang/en.ini
new file mode 100644
index 0000000..4f2d023
--- /dev/null
+++ b/internal/i18n/lang/en.ini
@@ -0,0 +1,79 @@
+version.drb = Douay-Rheims (English)
+version.grb = Greek
+version.vul = Vulgate (Latin)
+version.wuj = Wujek (Polish)
+part_label.aklamacja = Acclamation
+part_label.drugie_czytanie = 2nd reading
+part_label.ewangelia = Gospel
+part_label.pierwsze_czytanie = 1st reading
+part_label.psalm = Psalm
+footer_keys = tab/⇧tab version ←/→ day d date j/k scroll space/b page g/G top/bottom q quit
+loading = loading…
+no_readings_for = "no readings for "
+error_prefix = "error: "
+error_hint = change date (←/→)
+jump_prompt = go to date (YYYY-MM-DD)
+reader_title = reader — pick a book
+reader_pick_keys = type to filter ↑/↓ move enter open esc quit
+reader_read_keys = n/p chapter c go-to tab version j/k scroll m mark b bookmarks esc books q quit
+reader_no_text = (no text in this version)
+reader_no_match = (no matching books)
+reader_mark_prompt = note (enter to save, esc to cancel)
+reader_bookmarks_title = bookmarks
+reader_bookmarks_keys = j/k move enter open d delete esc back q quit
+reader_no_bookmarks = no bookmarks yet — press m while reading
+reader_mark_verse_keys = ↑/↓ pick verse enter add note esc cancel
+reader_confirm_delete = Delete this bookmark?
+reader_confirm_keys = y = delete n = cancel
+reader_mark_note = note
+reader_mark_tags = tags
+reader_mark_help = enter save · tab switch field · esc cancel
+reader_jump_chapter = go to chapter
+reader_jump_chapter_keys = enter go · esc cancel
+banner_gospel = Gospel
+banner_readings = Readings
+banner_connective = for
+months = January,February,March,April,May,June,July,August,September,October,November,December
+lectionary = lectionary
+opt_modern = modern
+opt_traditional = traditional
+parts = parts
+opt_gospel = Gospel
+opt_all = all parts
+layout = layout
+opt_horizontal = horizontal
+opt_columns = columns
+opt_interlinear = interlinear
+theme = theme
+mono = mono
+no_interlinear_verses = (no verses to align)
+nav_reader = reader
+web_book = book
+web_chapter = chapter
+nav_home = home
+nav_settings = settings
+nav_bookmarks = bookmarks
+web_verse_optional = verse (optional)
+web_tags_placeholder = tags, comma-separated
+web_add_bookmark = bookmark
+web_all_tags = all
+web_no_bookmarks = No bookmarks yet.
+web_delete = delete
+web_saved = Saved.
+web_ui_lang = UI language
+web_sigla_style = sigla style
+web_default_version = default version
+web_versions = versions
+web_web_versions = web versions
+web_offline = offline
+web_width = width
+web_port = web port
+web_pager = pager
+web_save = Save
+export = download
+calendar = month calendar
+no_readings_day = no readings for this day
+no_version = (not in %s)
+no_version_partial = (not in %s: %s)
+no_reference = (no reference)
+no_reference_err = (no reference: %v)
diff --git a/internal/i18n/lang/pl.ini b/internal/i18n/lang/pl.ini
new file mode 100644
index 0000000..1a202dd
--- /dev/null
+++ b/internal/i18n/lang/pl.ini
@@ -0,0 +1,79 @@
+version.drb = Douay-Rheims (ang.)
+version.grb = Grecki
+version.vul = Wulgata (lac.)
+version.wuj = Wujek (pol.)
+part_label.aklamacja = Aklamacja
+part_label.drugie_czytanie = 2. czytanie
+part_label.ewangelia = Ewangelia
+part_label.pierwsze_czytanie = 1. czytanie
+part_label.psalm = Psalm
+footer_keys = tab/⇧tab wersja ←/→ dzień d data j/k przewiń spacja/b strona g/G góra/dół q wyjście
+loading = ładowanie…
+no_readings_for = "brak czytań na "
+error_prefix = "błąd: "
+error_hint = zmień datę (←/→)
+jump_prompt = przejdź do daty (RRRR-MM-DD)
+reader_title = czytnik — wybierz księgę
+reader_pick_keys = wpisz, by filtrować ↑/↓ ruch enter otwórz esc wyjście
+reader_read_keys = n/p rozdział c skok tab wersja j/k przewiń m zakładka b zakładki esc księgi q wyjście
+reader_no_text = (brak tekstu w tej wersji)
+reader_no_match = (brak pasujących ksiąg)
+reader_mark_prompt = notatka (enter zapisz, esc anuluj)
+reader_bookmarks_title = zakładki
+reader_bookmarks_keys = j/k ruch enter otwórz d usuń esc wróć q wyjście
+reader_no_bookmarks = brak zakładek — naciśnij m podczas czytania
+reader_mark_verse_keys = ↑/↓ wybierz werset enter notatka esc anuluj
+reader_confirm_delete = Usunąć zakładkę?
+reader_confirm_keys = y = usuń n = anuluj
+reader_mark_note = notatka
+reader_mark_tags = tagi
+reader_mark_help = enter zapisz · tab pole · esc anuluj
+reader_jump_chapter = przejdź do rozdziału
+reader_jump_chapter_keys = enter przejdź · esc anuluj
+banner_gospel = Ewangelia
+banner_readings = Czytania
+banner_connective = na
+months = Styczeń,Luty,Marzec,Kwiecień,Maj,Czerwiec,Lipiec,Sierpień,Wrzesień,Październik,Listopad,Grudzień
+lectionary = lekcjonarz
+opt_modern = nowy
+opt_traditional = tradycyjny
+parts = zakres
+opt_gospel = Ewangelia
+opt_all = wszystkie części
+layout = układ
+opt_horizontal = poziomo
+opt_columns = kolumny
+opt_interlinear = interlinearnie
+theme = motyw
+mono = mono
+no_interlinear_verses = (brak wersetów do zestawienia interlinearnego)
+nav_reader = czytnik
+web_book = księga
+web_chapter = rozdział
+nav_home = strona główna
+nav_settings = ustawienia
+nav_bookmarks = zakładki
+web_verse_optional = werset (opcjonalnie)
+web_tags_placeholder = tagi, oddzielone przecinkami
+web_add_bookmark = zakładka
+web_all_tags = wszystkie
+web_no_bookmarks = Brak zakładek.
+web_delete = usuń
+web_saved = Zapisano.
+web_ui_lang = język interfejsu
+web_sigla_style = styl sigli
+web_default_version = wersja domyślna
+web_versions = wersje
+web_web_versions = wersje web
+web_offline = offline
+web_width = szerokość
+web_port = port web
+web_pager = pager
+web_save = Zapisz
+export = pobierz
+calendar = kalendarz miesiąca
+no_readings_day = brak czytań na ten dzień
+no_version = (brak w „%s”)
+no_version_partial = (brak w „%s”: %s)
+no_reference = (brak odwołania)
+no_reference_err = (brak odwołania: %w)
diff --git a/internal/i18n/override_test.go b/internal/i18n/override_test.go
new file mode 100644
index 0000000..bac5e2c
--- /dev/null
+++ b/internal/i18n/override_test.go
@@ -0,0 +1,28 @@
+package i18n
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestUserOverridePartialAndMap(t *testing.T) {
+ dir := t.TempDir()
+ os.WriteFile(filepath.Join(dir, "fr.ini"),
+ []byte("banner_gospel = Évangile\nversion.vul = Vulgate (latin)\n"), 0o644)
+ SetUserDir(dir)
+ t.Cleanup(func() { SetUserDir("") })
+ u := Get("fr")
+ if u.BannerGospel != "Évangile" {
+ t.Errorf("scalar override: BannerGospel = %q", u.BannerGospel)
+ }
+ if u.Version["vul"] != "Vulgate (latin)" {
+ t.Errorf("map override: version.vul = %q", u.Version["vul"])
+ }
+ if u.Version["drb"] != "Douay-Rheims (English)" {
+ t.Errorf("map fallback: version.drb = %q (want English baseline)", u.Version["drb"])
+ }
+ if u.BannerReadings != "Readings" {
+ t.Errorf("scalar fallback: BannerReadings = %q (want English)", u.BannerReadings)
+ }
+}