From 4b1218eec0068ba8b119a970385aea5b0605660b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 18 Aug 2026 09:32:26 -0400 Subject: [PATCH] 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. --- server/nativeapi/translations.go | 36 +++++++-- server/nativeapi/translations_test.go | 12 +++ ui/src/i18n/provider.js | 11 +-- ui/src/i18n/provider.test.js | 19 +++++ ui/src/i18n/useGetLanguageChoices.jsx | 31 +++++++- ui/src/i18n/useGetLanguageChoices.test.jsx | 92 ++++++++++++++++++++++ 6 files changed, 184 insertions(+), 17 deletions(-) create mode 100644 ui/src/i18n/provider.test.js create mode 100644 ui/src/i18n/useGetLanguageChoices.test.jsx diff --git a/server/nativeapi/translations.go b/server/nativeapi/translations.go index 685713083..39d071279 100644 --- a/server/nativeapi/translations.go +++ b/server/nativeapi/translations.go @@ -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) diff --git a/server/nativeapi/translations_test.go b/server/nativeapi/translations_test.go index 6c834070c..77c088f6f 100644 --- a/server/nativeapi/translations_test.go +++ b/server/nativeapi/translations_test.go @@ -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)) + }) }) }) diff --git a/ui/src/i18n/provider.js b/ui/src/i18n/provider.js index f17a5b4ac..58f1782bb 100644 --- a/ui/src/i18n/provider.js +++ b/ui/src/i18n/provider.js @@ -42,13 +42,14 @@ const removeEmpty = (obj) => { const prepareLanguage = (lang) => { removeEmpty(lang) + // Aliases below go on the merged copy: mutating `en` would corrupt the completion baseline + const merged = deepmerge(en, lang) // Make "albumSong" and "playlistTrack" resource use the same translations as "song" - lang.resources.albumSong = lang.resources.song - lang.resources.playlistTrack = lang.resources.song + merged.resources.albumSong = merged.resources.song + merged.resources.playlistTrack = merged.resources.song // ra.boolean.null should always be empty - lang.ra.boolean.null = '' - // Fallback to english translations - return deepmerge(en, lang) + merged.ra.boolean.null = '' + return merged } export default polyglotI18nProvider((locale) => { diff --git a/ui/src/i18n/provider.test.js b/ui/src/i18n/provider.test.js new file mode 100644 index 000000000..82b593788 --- /dev/null +++ b/ui/src/i18n/provider.test.js @@ -0,0 +1,19 @@ +import { describe, it, expect, vi } from 'vitest' +import en from './en.json' + +vi.mock('../dataProvider', () => ({ default: { getOne: vi.fn() } })) + +const countLeaves = (obj) => + Object.values(obj).reduce( + (sum, v) => + sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0), + 0, + ) + +describe('i18n provider', () => { + it('does not mutate the bundled English translations', async () => { + const before = countLeaves(en) + await import('./provider') + expect(countLeaves(en)).toEqual(before) + }) +}) diff --git a/ui/src/i18n/useGetLanguageChoices.jsx b/ui/src/i18n/useGetLanguageChoices.jsx index 0c708691f..38f95e422 100644 --- a/ui/src/i18n/useGetLanguageChoices.jsx +++ b/ui/src/i18n/useGetLanguageChoices.jsx @@ -1,5 +1,22 @@ // React Hook to get a list of all languages available. English is hardcoded import { useGetList } from 'react-admin' +import en from './en.json' + +const countLeaves = (obj) => + Object.values(obj).reduce( + (sum, v) => + sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0), + 0, + ) + +const enTermCount = countLeaves(en) + +const withPercentage = ({ id, name, termCount }) => { + if (!termCount) return { id, name } + const pct = Math.min(100, Math.round((100 * termCount) / enTermCount)) + // Isolate the percentage, or it renders as "(%61)" next to a right-to-left name + return { id, name: `${name} ⁦(${pct}%)⁩` } +} const useGetLanguageChoices = () => { const { ids, data, loaded, loading } = useGetList( @@ -9,13 +26,19 @@ const useGetLanguageChoices = () => { {}, ) - const choices = [{ id: 'en', name: 'English' }] + const languages = [{ id: 'en', name: 'English', termCount: enTermCount }] if (loaded) { - ids.forEach((id) => choices.push({ id: id, name: data[id].name })) + ids.forEach((id) => + languages.push({ + id, + name: data[id].name, + termCount: data[id].termCount, + }), + ) } - choices.sort((a, b) => a.name.localeCompare(b.name)) + languages.sort((a, b) => a.name.localeCompare(b.name)) - return { choices, loaded, loading } + return { choices: languages.map(withPercentage), loaded, loading } } export default useGetLanguageChoices diff --git a/ui/src/i18n/useGetLanguageChoices.test.jsx b/ui/src/i18n/useGetLanguageChoices.test.jsx new file mode 100644 index 000000000..b8214868e --- /dev/null +++ b/ui/src/i18n/useGetLanguageChoices.test.jsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from 'vitest' +import { renderHook } from '@testing-library/react-hooks' +import { useGetList } from 'react-admin' +import en from './en.json' +import useGetLanguageChoices from './useGetLanguageChoices' + +vi.mock('react-admin', () => ({ + useGetList: vi.fn(), +})) + +const countLeaves = (obj) => + Object.values(obj).reduce( + (sum, v) => + sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0), + 0, + ) +const enTermCount = countLeaves(en) + +// The percentage is wrapped in a left-to-right isolate, so it reads the same +// next to right-to-left language names +const label = (pct) => `⁦(${pct}%)⁩` + +const mockLanguages = (languages) => { + const data = {} + languages.forEach((l) => (data[l.id] = l)) + useGetList.mockReturnValue({ + ids: languages.map((l) => l.id), + data, + loaded: true, + loading: false, + }) +} + +const choiceFor = (id) => { + const { result } = renderHook(() => useGetLanguageChoices()) + return result.current.choices.find((c) => c.id === id) +} + +describe('useGetLanguageChoices', () => { + it('appends the completion percentage to incomplete languages', () => { + const termCount = Math.round(enTermCount * 0.62) + mockLanguages([{ id: 'cs', name: 'Čeština', termCount }]) + + const pct = Math.round((100 * termCount) / enTermCount) + expect(choiceFor('cs').name).toEqual(`Čeština ${label(pct)}`) + }) + + it('shows 100% for a complete language', () => { + mockLanguages([{ id: 'de', name: 'Deutsch', termCount: enTermCount }]) + + expect(choiceFor('de').name).toEqual(`Deutsch ${label(100)}`) + }) + + it('caps the percentage at 100 when a language has extra terms', () => { + mockLanguages([ + { id: 'pt', name: 'Português', termCount: enTermCount + 20 }, + ]) + + expect(choiceFor('pt').name).toEqual(`Português ${label(100)}`) + }) + + it('isolates the percentage next to a right-to-left name', () => { + const termCount = Math.round(enTermCount * 0.61) + mockLanguages([{ id: 'ar', name: 'العربية', termCount }]) + + const pct = Math.round((100 * termCount) / enTermCount) + expect(choiceFor('ar').name).toEqual(`العربية ⁦(${pct}%)⁩`) + }) + + it('omits the percentage when the server does not send a term count', () => { + mockLanguages([{ id: 'fr', name: 'Français' }]) + + expect(choiceFor('fr').name).toEqual('Français') + }) + + it('shows 100% for the bundled English', () => { + mockLanguages([]) + + expect(choiceFor('en').name).toEqual(`English ${label(100)}`) + }) + + it('sorts by language name, ignoring the percentage', () => { + mockLanguages([ + { id: 'no', name: 'Norsk', termCount: enTermCount }, + { id: 'da', name: 'Dansk', termCount: 1 }, + ]) + + const { result } = renderHook(() => useGetLanguageChoices()) + + expect(result.current.choices.map((c) => c.id)).toEqual(['da', 'en', 'no']) + }) +})