diff --git a/BACKLOG.md b/BACKLOG.md index 02f53888..0554f710 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -28,7 +28,8 @@ What is left, most needed first. The detail is in the item each names. 3. **K**: fixes from the fabric test the operator runs with docs/large-models.md. **Soon: the customer's experience, and HA.** -4. **C24**: English errors on Turkish pages. (S) +4. ~~**C24**~~: done 2026-10-21. The store's, catalog's and mirror's errors, percent signs, footer times and stale + forms are in the page's language. Left: the /docs documents, and a language kept per person (cookie only today). 5. **J4**: the model mirror on more than one machine, the last single point of an HA install. (M) 6. **F5**: large imports limited by the embedding engine's slots. (M) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd97245d..f3cda0c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,13 @@ APIs change between minors without a deprecation period. ### Fixed +- **Turkish pages are Turkish to the end.** + - **Errors:** those from the store, catalog and mirror that a page shows ("not found", "not empty", "already in the + catalog", the settings changed meanwhile, and others) are now in the page's language. + - **Percent:** a percentage is written the language's way: `%93` in Turkish, `93%` in English. + - **Footer:** the time at the foot of a page is in the language's date form. + - **Stale forms:** they are refused with a sentence in that language rather than "bad request". + - **`llmgw keys limits` no longer clears the limits it was not given.** Setting one limit at a scope wrote the whole row, so `--tools 'jira.*'` alone took away that scope's rate, budget and model list. Only what is given changes now. `--replace` writes every limit, as the console's limits form does. diff --git a/internal/web/account.go b/internal/web/account.go index 3f09caf5..d9c2c28d 100644 --- a/internal/web/account.go +++ b/internal/web/account.go @@ -344,7 +344,7 @@ func (p *AccountPages) logout(w http.ResponseWriter, r *http.Request) { // page can sign a visitor out of this one. sess, _, ok := p.session(r) if ok && !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { - http.Error(w, "bad request", http.StatusBadRequest) + badForm(w, r) return } if c, err := r.Cookie(SessionCookie); err == nil { @@ -437,7 +437,7 @@ func (p *AccountPages) createKey(w http.ResponseWriter, r *http.Request) { return } if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { - http.Error(w, "bad request", http.StatusBadRequest) + badForm(w, r) return } @@ -487,7 +487,7 @@ func (p *AccountPages) revokeKey(w http.ResponseWriter, r *http.Request) { return } if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { - http.Error(w, "bad request", http.StatusBadRequest) + badForm(w, r) return } @@ -721,7 +721,7 @@ func (p *AccountPages) changePassword(w http.ResponseWriter, r *http.Request) { return } if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { - http.Error(w, "bad request", http.StatusBadRequest) + badForm(w, r) return } diff --git a/internal/web/admin_mirror.go b/internal/web/admin_mirror.go index 95ce1be5..c1c41aa8 100644 --- a/internal/web/admin_mirror.go +++ b/internal/web/admin_mirror.go @@ -401,7 +401,7 @@ func (p *AccountPages) mirrorAction(m MirrorAdmin, return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { sess, _, _ := p.session(r) if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { - http.Error(w, "bad request", http.StatusBadRequest) + badForm(w, r) return } msg, err := do(r.Context(), admin, r) diff --git a/internal/web/admin_nodeengine.go b/internal/web/admin_nodeengine.go index 9d969261..8b6195a7 100644 --- a/internal/web/admin_nodeengine.go +++ b/internal/web/admin_nodeengine.go @@ -21,15 +21,16 @@ func (p *AccountPages) RegisterNodeEngines(mux *http.ServeMux, a NodeEngineAdmin mux.HandleFunc("POST /admin/nodes/{id}/engines", p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { sess, _, _ := p.session(r) if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { - http.Error(w, "bad request", http.StatusBadRequest) + badForm(w, r) return } node, engine, build := r.PathValue("id"), r.FormValue("engine"), r.FormValue("build") q := url.Values{} if err := a.SetNodeEngine(r.Context(), node, engine, build, admin.Email); err != nil { p.log.Warn("an engine build was not chosen", "admin", admin.Email, "node", node, "err", err) - // The control plane's reason, as it gave it: in English. - q.Set("problem", node+": "+err.Error()) + // The control plane's reason, in the page's language where it + // is one the pages know, else as it gave it. + q.Set("problem", node+": "+langOf(r).Err(err)) } else { p.log.Info("an engine build was chosen", "admin", admin.Email, "node", node, "engine", engine, "build", build) // Said in the language of the person who chose, since the diff --git a/internal/web/admin_presets.go b/internal/web/admin_presets.go index f2c75bd8..edb43d39 100644 --- a/internal/web/admin_presets.go +++ b/internal/web/admin_presets.go @@ -108,7 +108,7 @@ func (p *AccountPages) RegisterPresets(mux *http.ServeMux, a PresetAdmin) { return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { sess, _, _ := p.session(r) if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { - http.Error(w, "bad request", http.StatusBadRequest) + badForm(w, r) return } h(w, r, admin) @@ -120,7 +120,7 @@ func (p *AccountPages) RegisterPresets(mux *http.ServeMux, a PresetAdmin) { q := url.Values{} if err := a.SetAutoPresets(r.Context(), on, admin.Email); err != nil { p.log.Warn("presets.auto was not changed", "admin", admin.Email, "err", err) - q.Set("problem", err.Error()) + q.Set("problem", langOf(r).Err(err)) } else { p.log.Info("presets.auto changed", "admin", admin.Email, "on", on) key := "dash.presets.notice.auto_off" @@ -136,8 +136,9 @@ func (p *AccountPages) RegisterPresets(mux *http.ServeMux, a PresetAdmin) { q := url.Values{} if err := a.AddPreset(r.Context(), name, admin.Email); err != nil { p.log.Warn("a preset was not added", "admin", admin.Email, "preset", name, "err", err) - // The control plane's reason, as it gave it: in English. - q.Set("problem", name+": "+err.Error()) + // The control plane's reason, in the page's language where it + // is one the pages know, else as it gave it. + q.Set("problem", name+": "+langOf(r).Err(err)) } else { p.log.Info("a preset was added", "admin", admin.Email, "preset", name) q.Set("notice", langOf(r).T("dash.presets.notice.added", name)) diff --git a/internal/web/admin_quotas.go b/internal/web/admin_quotas.go index 4e0b27c0..79dcb2c6 100644 --- a/internal/web/admin_quotas.go +++ b/internal/web/admin_quotas.go @@ -210,7 +210,7 @@ func (p *AccountPages) RegisterAdminQuotas(mux *http.ServeMux, s QuotaAdmin, pri func(w http.ResponseWriter, r *http.Request, admin store.User) { sess, _, _ := p.session(r) if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { - http.Error(w, "bad request", http.StatusBadRequest) + badForm(w, r) return } msg, err := p.applyQuota(r, s, admin) @@ -228,7 +228,7 @@ func (p *AccountPages) RegisterAdminQuotas(mux *http.ServeMux, s QuotaAdmin, pri func(w http.ResponseWriter, r *http.Request, admin store.User) { sess, _, _ := p.session(r) if p.capacity == nil || !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { - http.Error(w, "bad request", http.StatusBadRequest) + badForm(w, r) return } msg, err := p.applyCapacity(r, admin) diff --git a/internal/web/admin_settings.go b/internal/web/admin_settings.go index fd5e376f..ce17ef3f 100644 --- a/internal/web/admin_settings.go +++ b/internal/web/admin_settings.go @@ -89,7 +89,7 @@ func (p *AccountPages) RegisterAdminSettings(mux *http.ServeMux, s SettingsAdmin if r.Method == http.MethodPost { sess, _, _ := p.session(r) if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { - http.Error(w, "bad request", http.StatusBadRequest) + badForm(w, r) return } } diff --git a/internal/web/admin_users.go b/internal/web/admin_users.go index d27eeefc..d19b9772 100644 --- a/internal/web/admin_users.go +++ b/internal/web/admin_users.go @@ -476,7 +476,7 @@ func (p *AccountPages) tenancyAction(s AccountAdmin, return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { sess, _, _ := p.session(r) if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { - http.Error(w, "bad request", http.StatusBadRequest) + badForm(w, r) return } l := langOf(r) @@ -524,7 +524,7 @@ func (p *AccountPages) adminAction(s AccountAdmin, return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { sess, _, _ := p.session(r) if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { - http.Error(w, "bad request", http.StatusBadRequest) + badForm(w, r) return } id := r.PathValue("id") diff --git a/internal/web/chat.go b/internal/web/chat.go index 9262e705..4e13f036 100644 --- a/internal/web/chat.go +++ b/internal/web/chat.go @@ -125,7 +125,7 @@ func (p *AccountPages) Guard( token = r.FormValue("csrf") } if !p.accounts.CheckCSRF(sess, token) { - http.Error(w, "bad request", http.StatusBadRequest) + badForm(w, r) return } } diff --git a/internal/web/connect.go b/internal/web/connect.go index 83851ed1..f6333941 100644 --- a/internal/web/connect.go +++ b/internal/web/connect.go @@ -164,7 +164,7 @@ func (p *AccountPages) connect(w http.ResponseWriter, r *http.Request) { return } if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { - http.Error(w, "bad request", http.StatusBadRequest) + badForm(w, r) return } l := langOf(r) diff --git a/internal/web/errors_i18n.go b/internal/web/errors_i18n.go new file mode 100644 index 00000000..6a12ae27 --- /dev/null +++ b/internal/web/errors_i18n.go @@ -0,0 +1,44 @@ +package web + +import ( + "net/http" + + "gitea.mynodes.xyz/ilker/llmgw/internal/account" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" + "gitea.mynodes.xyz/ilker/llmgw/internal/mirror" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// The errors of other packages a page can show, in the page's language: an +// administrator's page in Turkish said "not found" and "not empty" in +// English, because they came from the store as written there. +func init() { + for err, key := range map[error]string{ + store.ErrNotFound: "err.not_found", + store.ErrNotEmpty: "err.not_empty", + store.ErrDuplicateName: "err.duplicate_name", + store.ErrEmailTaken: "err.email_taken", + store.ErrKeyTTL: "err.key_ttl", + store.ErrNotMember: "err.not_member", + store.ErrOwnerInactive: "err.owner_inactive", + store.ErrSettingsChanged: "err.settings_changed", + store.ErrNoSession: "err.no_session", + store.ErrKnowledgeOff: "err.knowledge_off", + store.ErrMemoryNotEnabled: "err.memory_off", + store.ErrMemoryFull: "err.memory_full", + store.ErrThreadsNotEnabled: "err.threads_off", + account.ErrNoReset: "err.no_reset", + catalog.ErrExists: "err.in_catalog", + mirror.ErrAlreadyFetching: "err.mirror_fetching", + mirror.ErrExists: "err.mirror_exists", + mirror.ErrIncomplete: "err.mirror_incomplete", + } { + registerErrorKey(err, key) + } +} + +// badForm answers a form whose token did not match: one left open too long, +// or sent from another site. In the page's language, plainly. +func badForm(w http.ResponseWriter, r *http.Request) { + http.Error(w, langOf(r).T("err.bad_form"), http.StatusBadRequest) +} diff --git a/internal/web/errors_i18n_test.go b/internal/web/errors_i18n_test.go new file mode 100644 index 00000000..b3cf5ad5 --- /dev/null +++ b/internal/web/errors_i18n_test.go @@ -0,0 +1,41 @@ +package web + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// A Turkish page says the store's errors, percentages and moments the +// Turkish way, and a stale form plainly. +func TestATurkishPageSpeaksTurkishToTheEnd(t *testing.T) { + tr, en := langByCode("tr"), langByCode("en") + wrapped := fmt.Errorf("org Acme: %w", store.ErrNotEmpty) + if got := tr.Err(wrapped); !strings.Contains(got, "boş değil") { + t.Errorf("a wrapped store error in Turkish: %q", got) + } + if got := en.Err(store.ErrNotFound); !strings.Contains(got, "not there") { + t.Errorf("in English: %q", got) + } + if got := tr.Pct(93, 0); got != "%93" { + t.Errorf("Turkish percent %q", got) + } + if got := en.Pct(93.5, 1); got != "93.5%" { + t.Errorf("English percent %q", got) + } + stamp := tr.funcs()["stamp"].(func(string) string) + if got := stamp("2026-10-21T08:05:00Z"); got != "21.10.2026 08:05 UTC" { + t.Errorf("a Turkish moment %q", got) + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/x", nil) + req.Header.Set("Accept-Language", "tr") + badForm(rec, req) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "form süresi") { + t.Errorf("a stale form in Turkish: %d %q", rec.Code, rec.Body.String()) + } +} diff --git a/internal/web/i18n.go b/internal/web/i18n.go index 45952148..2ce0e796 100644 --- a/internal/web/i18n.go +++ b/internal/web/i18n.go @@ -55,6 +55,9 @@ type Lang struct { decimal, group string // date and dateTime are Go layouts. date, dateTime string + // percent writes a percentage around its number: "%s%%" in English, + // "%%%s" in Turkish, which puts the sign first. + percent string tmpl *template.Template } @@ -149,6 +152,7 @@ func loadLang(fsys fs.FS, code string) (*Lang, error) { l.group = l.msgs["_meta.group"] l.date = cmpOr(l.msgs["_meta.date"], "2006-01-02") l.dateTime = cmpOr(l.msgs["_meta.datetime"], "2006-01-02 15:04") + l.percent = cmpOr(l.msgs["_meta.percent"], "%s%%") return l, nil } @@ -375,6 +379,9 @@ func registerKeys(keys ...string) { // Numbers. +// Pct writes a percentage the way this language does: 93% or %93. +func (l *Lang) Pct(v float64, places int) string { return fmt.Sprintf(l.percent, l.Num(v, places)) } + // Num writes v with places decimals, in this language's separators. func (l *Lang) Num(v float64, places int) string { if math.IsNaN(v) || math.IsInf(v, 0) { @@ -624,6 +631,17 @@ func (l *Lang) funcs() template.FuncMap { // a style attribute or an SVG coordinate, which are always written // the C way. "num": func(v any, places int) string { return l.Num(toFloat(v), places) }, + // stamp is a moment kept as RFC 3339, the way this language writes + // dates and times. + "stamp": func(s string) string { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return s + } + return t.UTC().Format(l.dateTime) + " UTC" + }, + // pc is a percentage, its sign where the language puts it. + "pc": func(v any, places int) string { return l.Pct(toFloat(v), places) }, "count": func(v any) string { if n, ok := toInt(v); ok { return l.Int(n) diff --git a/internal/web/i18n_cluster_test.go b/internal/web/i18n_cluster_test.go index 4ec6f1ed..5411cb95 100644 --- a/internal/web/i18n_cluster_test.go +++ b/internal/web/i18n_cluster_test.go @@ -37,7 +37,7 @@ func TestTheDashboardSpeaksTurkishWhenAsked(t *testing.T) { // A state is still its class, and a Turkish word. `
{{t "profile.password_note"}}
- +