From 9100973576f87adcadaf6c8b331df7e729aeb117 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 28 Jul 2026 19:03:15 +0200 Subject: i18n: make all UI chrome user-translatable via ui/.ini The interface chrome (version labels, TUI keybar/messages, CLI banner words, web control labels, section-heading words) was English/Polish hardcoded in Go. It is now data: the English and Polish tables ship as embedded lang/en.ini and lang/pl.ini, and any language is user-overridable at /ui/.ini, resolved per key with an English fallback. With ui_language = plus a names/.ini, the WHOLE interface -- chrome, day names, saint names -- is translatable without a rebuild. - i18n.Get loads the embedded English baseline, overlays the embedded/user language file, caches per lang; field <-> INI-key mapping is by reflection (lang.go) so the tables and files stay in sync automatically. Edge-space values (e.g. "error: ") are double-quoted so the INI trim keeps them. The Go tables move to golden_test.go; TestGoldenMatchesEmbedded asserts the embedded files still parse back to them, and TestGenerateLangFiles regenerates them (LECTIO_GEN=1). - config.UIDir(); wire i18n.SetUserDir in the CLI, TUI and web entry points. - README/config: document ui/.ini alongside names/.ini. --- internal/cli/cli.go | 3 + internal/config/config.go | 15 +++- internal/i18n/gen_test.go | 69 +++++++++++++++ internal/i18n/golden_test.go | 191 ++++++++++++++++++++++++++++++++++++++++ internal/i18n/i18n.go | 195 ++--------------------------------------- internal/i18n/lang.go | 155 ++++++++++++++++++++++++++++++++ internal/i18n/lang/en.ini | 79 +++++++++++++++++ internal/i18n/lang/pl.ini | 79 +++++++++++++++++ internal/i18n/override_test.go | 28 ++++++ 9 files changed, 627 insertions(+), 187 deletions(-) create mode 100644 internal/i18n/gen_test.go create mode 100644 internal/i18n/golden_test.go create mode 100644 internal/i18n/lang.go create mode 100644 internal/i18n/lang/en.ini create mode 100644 internal/i18n/lang/pl.ini create mode 100644 internal/i18n/override_test.go (limited to 'internal') 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/.ini) +# ui_language any code (en, pl, ...) (chrome from ui/.ini, day names from names/.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 +// (.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 = 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 // (.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/.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 /ui/.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 +// (/.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/.ini (Polish ships; other codes fall through), overlaid +// by the user's /.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/.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/.ini files (generated from the Go tables, see the +// gen test), and any language is user-overridable at /ui/.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) + } +} -- cgit v1.3