feat(ui): show translation completion percentage in the language selector (#5979)

The language selector now shows how complete each translation is, so users
can see at a glance which languages are lagging behind English. The native
API's translation resource gained a termCount field holding the number of
non-empty terms in each language file; the UI divides that by the term count
of the bundled English file to get the percentage.

The percentage is wrapped in a Unicode left-to-right isolate, otherwise it
renders as "(%61)" beside right-to-left names such as Arabic and Persian.
Sorting runs on the plain language name, before the percentage is appended.

This also fixes prepareLanguage() mutating the bundled English translations:
for the English locale it received the shared en object and aliased albumSong
and playlistTrack onto it, growing en by 94 keys at runtime. That inflated the
denominator and made every language read about 14 points low. The aliases now
go on the merged copy instead.
This commit is contained in:
Deluan Quintão 2026-08-18 09:32:26 -04:00 • committed by GitHub
commit 4b1218eec0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 184 additions and 17 deletions

View file

@ -17,9 +17,10 @@ import (
)
type translation struct {
ID string `json:"id"`
Name string `json:"name"`
Data string `json:"data"`
ID string `json:"id"`
Name string `json:"name"`
Data string `json:"data"`
TermCount int `json:"termCount"`
}
func newTranslationRepository(context.Context) rest.Repository {
@ -97,27 +98,46 @@ func loadTranslation(fsys fs.FS, fileName string) (translation translation, err
// Load translation from json file
file, err := fsys.Open(filePath)
if err != nil {
return
return translation, err
}
data, err := io.ReadAll(file)
if err != nil {
return
return translation, err
}
var out map[string]any
if err = json.Unmarshal(data, &out); err != nil {
return
return translation, err
}
// Compress JSON
buf := new(bytes.Buffer)
if err = json.Compact(buf, data); err != nil {
return
return translation, err
}
translation.Data = buf.String()
translation.Name = out["languageName"].(string)
translation.ID = id
return
translation.TermCount = countTranslatedTerms(out)
return translation, nil
}
// countTranslatedTerms counts non-empty leaf values, matching the UI's notion of a translated term
func countTranslatedTerms(obj map[string]any) int {
count := 0
for _, v := range obj {
switch v := v.(type) {
case map[string]any:
count += countTranslatedTerms(v)
case string:
if v != "" {
count++
}
default:
count++
}
}
return count
}
var _ rest.Repository = (*translationRepository)(nil)

View file

@ -6,6 +6,7 @@ import (
"io/fs"
"os"
"path/filepath"
"testing/fstest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/resources"
@ -45,5 +46,16 @@ var _ = Describe("Translations", func() {
var out map[string]any
Expect(json.Unmarshal([]byte(tr.Data), &out)).To(BeNil())
})
It("counts only non-empty leaf terms", func() {
fsys := fstest.MapFS{
"i18n/test.json": &fstest.MapFile{
Data: []byte(`{"languageName":"Test","a":"x","b":"","nested":{"c":"y","d":""}}`),
},
}
tr, err := loadTranslation(fsys, "test.json")
Expect(err).To(BeNil())
Expect(tr.TermCount).To(Equal(3))
})
})
})