mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(ui): show add-on titles and descriptions in the language of the user
The library cards, the first-run list, the install dialog and the upgrade dialogs now name an add-on by its translated title and description, taken from the library entry or the installed record. A language without text, and English, keep the English text, field by field. The installed list and the library page are in the alphabetical order of the language of the user, and a search finds an add-on by its translated name and by its English name. The Home commands test and the provider strings test read the English title now that the title of an add-on is a language map. Assisted-by: Claude:claude-sonnet-5-5 javascript
This commit is contained in:
11 files changed
+227
-18
No files matched your search
@@ -800,3 +800,78 @@ test.describe('Add-ons: shared catalog components keep the backends gallery', ()
|
||||
await expect(page.locator('.ui-empty .ui-empty__title')).toHaveText('No backends found')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Add-ons: translated titles and descriptions', () => {
|
||||
const translated = {
|
||||
ui: {
|
||||
title: 'Tables', description: 'Databases of tables.', mainPage: 'databases',
|
||||
titles: { it: 'Tabelle', 'pt-BR': 'Tabelas' }, descriptions: { it: 'Database di tabelle tue o condivise.' },
|
||||
},
|
||||
}
|
||||
const library = () => [
|
||||
release('tables', { name: 'Tables', description: 'Databases of tables.', ...translated }),
|
||||
release('voice', { name: 'Voice', description: 'Speak with voices.', ui: { title: 'Voice', description: 'Speak with voices.', titles: { it: 'Voce' } } }),
|
||||
release('notes', { name: 'Notes', description: 'Keep notes.' }),
|
||||
]
|
||||
const language = (code) => (page) => page.addInitScript(c => localStorage.setItem('localai-language', c), code)
|
||||
|
||||
test('library: the cards show the title and description in the user language, and English per field where a language has none', async ({ page }) => {
|
||||
await language('it')(page)
|
||||
await signIn(page, ADMIN)
|
||||
await mockAddons(page, { installed: [], releases: library() })
|
||||
await page.goto('/app/addons?tab=browse')
|
||||
const tables = page.locator('[data-addon="tables"]')
|
||||
await expect(tables.locator('.ad-entry__title')).toHaveText('Tabelle')
|
||||
await expect(tables).toContainText('Database di tabelle tue o condivise.')
|
||||
const voice = page.locator('[data-addon="voice"]')
|
||||
await expect(voice.locator('.ad-entry__title')).toHaveText('Voce')
|
||||
await expect(voice).toContainText('Speak with voices.')
|
||||
const notes = page.locator('[data-addon="notes"]')
|
||||
await expect(notes.locator('.ad-entry__title')).toHaveText('Notes')
|
||||
await expect(notes).toContainText('Keep notes.')
|
||||
await shoot(page, 'translations-library-it')
|
||||
})
|
||||
|
||||
test('library: English and an unlisted language keep the English text', async ({ page }) => {
|
||||
await language('de')(page)
|
||||
await signIn(page, ADMIN)
|
||||
await mockAddons(page, { installed: [], releases: library() })
|
||||
await page.goto('/app/addons?tab=browse')
|
||||
await expect(page.locator('[data-addon="tables"] .ad-entry__title')).toHaveText('Tables')
|
||||
await expect(page.locator('[data-addon="tables"]')).toContainText('Databases of tables.')
|
||||
})
|
||||
|
||||
test('library: the install dialog names the add-on in the user language', async ({ page }) => {
|
||||
await language('it')(page)
|
||||
await signIn(page, ADMIN)
|
||||
await mockAddons(page, { installed: [], releases: library() })
|
||||
await page.goto('/app/addons?tab=browse')
|
||||
await page.locator('[data-addon="tables"]').getByRole('button').first().click()
|
||||
const dialog = page.getByRole('alertdialog')
|
||||
await expect(dialog).toContainText('Tabelle')
|
||||
await expect(dialog).not.toContainText('Tables?')
|
||||
})
|
||||
|
||||
test('installed list: tiles show the translated name and a search finds it by the translated or the English name', async ({ page }) => {
|
||||
await language('it')(page)
|
||||
await signIn(page, MEMBER, { addons: true })
|
||||
await mockAddons(page, { apps: [
|
||||
app({ id: 'tables', name: 'tables', title: 'Tables', titles: { en: 'Tables', it: 'Tabelle' }, description: 'Databases.', descriptions: { it: 'Database.' } }),
|
||||
app({ id: 'voice', name: 'voice', title: 'Voice', titles: { it: 'Voce' } }),
|
||||
] })
|
||||
await page.goto('/app/addons')
|
||||
await expect(page.locator('[data-app="tables"] .ad-tile__title')).toHaveText('Tabelle')
|
||||
await expect(page.locator('[data-app="voice"] .ad-tile__title')).toHaveText('Voce')
|
||||
const search = page.getByRole('searchbox').first()
|
||||
for (const [text, shown, hidden] of [['tabelle', 'tables', 'voice'], ['TABLES', 'tables', 'voice'], ['voce', 'voice', 'tables'], ['voice', 'voice', 'tables']]) {
|
||||
await search.fill(text)
|
||||
await expect(page.locator(`[data-app="${shown}"]`)).toHaveCount(1)
|
||||
await expect(page.locator(`[data-app="${hidden}"]`)).toHaveCount(0)
|
||||
}
|
||||
await search.fill('')
|
||||
await page.goto('/app/addons?detail=tables')
|
||||
await expect(page.getByTestId('addon-detail')).toContainText('Tabelle')
|
||||
await expect(page.getByTestId('addon-detail')).toContainText('Database.')
|
||||
await shoot(page, 'translations-detail-it')
|
||||
})
|
||||
})
|
||||
@@ -27,12 +27,14 @@ import { useBulkUpgrade } from '../../hooks/useBulkUpgrade'
|
||||
import { updateView, upgradeAvailability } from './updateState'
|
||||
import { upgradeFailureWords } from './updateReview'
|
||||
import FirstRun from './FirstRun'
|
||||
import { addedByAddon, attentionOf, filterByState, listState, loadLastKnown, saveLastKnown, sortByAttention, staleKind, STATE_FILTERS, upgradingTitles } from './addonState'
|
||||
import { addedByAddon, attentionOf, filterByState, listState, loadLastKnown, localizedText, localizeRecord, saveLastKnown, sortByAttention, staleKind, STATE_FILTERS, upgradingTitles } from './addonState'
|
||||
import { describeError, describeOutcome } from './errors'
|
||||
import ApproveUpgradeDialog, { WaitingBanner } from './ApproveUpgradeDialog'
|
||||
import { pendingApprovals } from './hostGrantsState'
|
||||
import { useAddonActions } from './useAddonActions'
|
||||
|
||||
const NO_RECORDS = {}
|
||||
|
||||
// The apps every caller with the add-ons feature may use. The owner decides
|
||||
// what is listed; this view only draws it. The list is refreshed on a bounded
|
||||
// cadence, slower in a hidden tab and backing off while the owner is
|
||||
@@ -118,7 +120,9 @@ export default function AppsView({ api, adapter, onSummary, grants }) {
|
||||
// The installed record of an add-on, for the administrator's Details in the
|
||||
// pane. A change reads it again.
|
||||
const [installedRecords, setInstalledInfo] = useState({})
|
||||
const installedInfo = installedRecords.actor === actor ? installedRecords.value : {}
|
||||
const installedRaw = installedRecords.actor === actor ? installedRecords.value : NO_RECORDS
|
||||
// The records name an add-on in dialogs and cards: in the user's language.
|
||||
const installedInfo = useMemo(() => (installedRaw === NO_RECORDS ? NO_RECORDS : Object.fromEntries(Object.entries(installedRaw).map(([id, rec]) => [id, localizeRecord(rec, i18n.language)]))), [installedRaw, i18n.language])
|
||||
const [installedReload, setInstalledReload] = useState(0)
|
||||
const actions = useAddonActions(api, {
|
||||
onSettled: () => {
|
||||
@@ -249,9 +253,9 @@ export default function AppsView({ api, adapter, onSummary, grants }) {
|
||||
return state.apps.filter(a => a.update === 'update-pending' || updateView(installedInfo[a.id]?.update).state === 'update-available').map(a => {
|
||||
const rec = installedInfo[a.id]
|
||||
const av = upgradeAvailability(rec, { isAdmin, pending: true })
|
||||
return { id: a.id, name: a.title || a.id, from: rec ? rec.version : a.version, to: rec ? updateView(rec.update).version : '', startable: av.startable, reason: reasonText(av, t), addon: rec }
|
||||
return { id: a.id, name: localizedText(a, 'title', i18n.language) || a.id, from: rec ? rec.version : a.version, to: rec ? updateView(rec.update).version : '', startable: av.startable, reason: reasonText(av, t), addon: rec }
|
||||
})
|
||||
}, [state.apps, installedInfo, isAdmin, t])
|
||||
}, [state.apps, installedInfo, isAdmin, t, i18n.language])
|
||||
const bulk = useBulkUpgrade({
|
||||
items: upgradeItems,
|
||||
runOne: async (item) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { AppIcon, UnknownNotice, VersionText } from './AddonBits'
|
||||
import { Button, Notice, StatusChip } from '../ui'
|
||||
import FlowStatus from './InstallFlow'
|
||||
import InstallDialog from './InstallDialog'
|
||||
import { addedByEntry } from './addonState'
|
||||
import { addedByEntry, localizeEntry, localizeRecord, sortEntriesByTitle } from './addonState'
|
||||
import { updateView, entryUpdateNote, updateNoticeSeverity, upgradeAvailability } from './updateState'
|
||||
import { describeError, describeOutcome } from './errors'
|
||||
import { useAddonActions } from './useAddonActions'
|
||||
@@ -22,7 +22,7 @@ import { UpdateBadge } from '../upgrade/UpgradeBits'
|
||||
// the other. An add-on that is installed but off offers Turn on. Backends are
|
||||
// not listed here: they belong to the Backends page.
|
||||
export default function BrowseView({ api, adapter }) {
|
||||
const { t } = useTranslation('addons')
|
||||
const { t, i18n } = useTranslation('addons')
|
||||
const [params, setParams] = useSearchParams()
|
||||
const [installed, setInstalled] = useState({})
|
||||
const [reload, setReload] = useState(0)
|
||||
@@ -53,8 +53,9 @@ export default function BrowseView({ api, adapter }) {
|
||||
|
||||
const openInstall = (entry) => setDialog(entry)
|
||||
|
||||
const renderEntry = (entry) => {
|
||||
const have = installed[entry.id]
|
||||
const renderEntry = (raw) => {
|
||||
const entry = localizeEntry(raw, i18n.language)
|
||||
const have = installed[entry.id] ? localizeRecord(installed[entry.id], i18n.language) : undefined
|
||||
const flow = actions.flows[`flow:${entry.key}`]
|
||||
const pending = Boolean(actions.unknown[entry.id])
|
||||
const working = ['install', 'turn-on'].includes(flow?.step)
|
||||
@@ -127,6 +128,7 @@ export default function BrowseView({ api, adapter }) {
|
||||
query={query}
|
||||
onQueryChange={onQueryChange}
|
||||
renderEntry={renderEntry}
|
||||
orderEntries={entries => sortEntriesByTitle(entries, i18n.language)}
|
||||
reloadToken={reload}
|
||||
describeError={e => describeError(e, t)}
|
||||
searchLabel={t('browse.search')}
|
||||
|
||||
@@ -5,14 +5,14 @@ import { Button, Callout, EmptyState, SkeletonRows } from '../ui'
|
||||
import { AppIcon, EntryAdds, VersionText } from './AddonBits'
|
||||
import FlowStatus from './InstallFlow'
|
||||
import InstallDialog from './InstallDialog'
|
||||
import { featuredEntries } from './addonState'
|
||||
import { featuredEntries, localizeEntry } from './addonState'
|
||||
import { describeError } from './errors'
|
||||
|
||||
// Nothing is installed yet. An administrator sees what add-ons are for, the
|
||||
// way to find them, and three add-ons from the sources as rows with Install:
|
||||
// one confirmation, then the install and the turn on run one after the other.
|
||||
export default function FirstRun({ adapter, actions }) {
|
||||
const { t } = useTranslation('addons')
|
||||
const { t, i18n } = useTranslation('addons')
|
||||
const [state, setState] = useState({ status: 'loading', entries: [], error: null })
|
||||
const [confirm, setConfirm] = useState(null)
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function FirstRun({ adapter, actions }) {
|
||||
<>
|
||||
<h3 className="ad-first__label">{t('apps.firstRun.featuredLabel')}</h3>
|
||||
<ul className="ad-featured" aria-label={t('apps.firstRun.featuredLabel')} data-testid="featured">
|
||||
{state.entries.map(entry => (
|
||||
{state.entries.map(raw => localizeEntry(raw, i18n.language)).map(entry => (
|
||||
<li key={entry.key} className="ad-entry ad-featured__item" data-addon={entry.id}>
|
||||
<AppIcon src="" id={entry.id} name={entry.title} />
|
||||
<div className="ad-entry__body">
|
||||
|
||||
@@ -86,6 +86,44 @@ export function localizedText(app, field, language) {
|
||||
return fallback || ''
|
||||
}
|
||||
|
||||
// An installed record with its title and description in the user's language,
|
||||
// for the dialogs and cards that name an add-on. The English text stays in
|
||||
// englishTitle and englishDescription, which the search reads too.
|
||||
export function localizeRecord(rec, language) {
|
||||
if (!rec) return rec
|
||||
return {
|
||||
...rec,
|
||||
title: localizedText(rec, 'title', language) || rec.title,
|
||||
description: localizedText(rec, 'description', language),
|
||||
englishTitle: rec.title,
|
||||
englishDescription: rec.description || '',
|
||||
}
|
||||
}
|
||||
|
||||
// A library entry with its title and description in the user's language. The
|
||||
// entry carries the other languages in titles and descriptions (from the
|
||||
// library); a language without text, and English, keep the entry's own text.
|
||||
export function localizeEntry(entry, language) {
|
||||
if (!entry) return entry
|
||||
return {
|
||||
...entry,
|
||||
title: localizedText({ id: entry.id, name: entry.title, title: entry.title, titles: entry.titles }, 'title', language) || entry.title,
|
||||
description: localizedText({ description: entry.description, descriptions: entry.descriptions }, 'description', language),
|
||||
englishTitle: entry.title,
|
||||
englishDescription: entry.description || '',
|
||||
}
|
||||
}
|
||||
|
||||
// One page of library entries in the alphabetical order of the user's language,
|
||||
// by the title that the person reads. The id breaks a tie, so the order is
|
||||
// stable. It orders the page as drawn; the library still pages by its own key.
|
||||
export function sortEntriesByTitle(entries, language) {
|
||||
let collator
|
||||
try { collator = new Intl.Collator(language || undefined, { sensitivity: 'base', numeric: true }) } catch { collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }) }
|
||||
const title = (e) => localizeEntry(e, language).title || e.id
|
||||
return [...(entries || [])].sort((a, b) => collator.compare(title(a), title(b)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
}
|
||||
|
||||
// The name to show for an add-on the list does not carry (not installed, or
|
||||
// not usable by this viewer): its identifier with a capital first letter, so
|
||||
// the page says "Voice" and not "voice".
|
||||
|
||||
@@ -18,10 +18,11 @@ import './catalog.css'
|
||||
// onQueryChange (nextQuery) => void
|
||||
// renderEntry (entry) => node, one list item body
|
||||
// reloadToken change it to search again with the same query
|
||||
// orderEntries optional (entries) => entries, the order of one page as drawn
|
||||
// describeError (error) => string for a failed search
|
||||
//
|
||||
// Every search is cancelled when the query changes or the view unmounts.
|
||||
export default function CatalogView({ adapter, query, onQueryChange, renderEntry, reloadToken = 0, describeError, emptyTitle, emptyText, searchLabel, pageSize = adapter.pageSize || PAGE_SIZE }) {
|
||||
export default function CatalogView({ adapter, query, onQueryChange, renderEntry, reloadToken = 0, orderEntries, describeError, emptyTitle, emptyText, searchLabel, pageSize = adapter.pageSize || PAGE_SIZE }) {
|
||||
const { t } = useTranslation('addons')
|
||||
const normalized = useMemo(() => normalizeQuery(query), [query])
|
||||
const key = queryKey(normalized)
|
||||
@@ -106,7 +107,7 @@ export default function CatalogView({ adapter, query, onQueryChange, renderEntry
|
||||
/>
|
||||
) : (
|
||||
<ul className="cat-list" aria-busy={state.status === 'loading'} aria-label={label}>
|
||||
{state.entries.map(entry => (
|
||||
{(orderEntries ? orderEntries(state.entries) : state.entries).map(entry => (
|
||||
<li key={entry.key} className="cat-item">{renderEntry(entry)}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
+14
-2
@@ -21,6 +21,9 @@ export function tileFromApp(app, { language = 'en' } = {}) {
|
||||
id: app.id,
|
||||
source: 'app',
|
||||
title: localizedText(app, 'title', language) || app.id,
|
||||
// The English name, so a search finds the add-on by it in any language.
|
||||
englishTitle: app.title || app.id,
|
||||
englishDescription: app.description || '',
|
||||
titleKey: '',
|
||||
description: localizedText(app, 'description', language) || '',
|
||||
icon: app.icon || '',
|
||||
@@ -39,6 +42,8 @@ export function tileFromBuiltin(entry) {
|
||||
id: entry.id,
|
||||
source: 'builtin',
|
||||
title: entry.id,
|
||||
englishTitle: entry.id,
|
||||
englishDescription: '',
|
||||
titleKey: entry.titleKey || '',
|
||||
description: '',
|
||||
icon: '',
|
||||
@@ -54,10 +59,14 @@ export function tileFromBuiltin(entry) {
|
||||
export function buildTiles(apps, builtin, { language = 'en' } = {}) {
|
||||
const first = (builtin || []).map(tileFromBuiltin)
|
||||
const taken = new Set(first.map(t => t.id))
|
||||
// The apps are in the alphabetical order of the language of the user, by the
|
||||
// title they read; the id breaks a tie so the order is stable.
|
||||
let collator
|
||||
try { collator = new Intl.Collator(language || undefined, { sensitivity: 'base', numeric: true }) } catch { collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }) }
|
||||
const rest = [...(apps || [])]
|
||||
.filter(a => !taken.has(a.id))
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
.map(a => tileFromApp(a, { language }))
|
||||
.sort((a, b) => collator.compare(a.title, b.title) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
return [...first, ...rest]
|
||||
}
|
||||
|
||||
@@ -65,7 +74,10 @@ export function buildTiles(apps, builtin, { language = 'en' } = {}) {
|
||||
export function filterTiles(tiles, query, titleOf = (t) => t.title) {
|
||||
const q = String(query || '').trim().toLowerCase()
|
||||
if (!q) return tiles
|
||||
return tiles.filter(t => titleOf(t).toLowerCase().includes(q) || t.description.toLowerCase().includes(q))
|
||||
// The text in the user's language and the English text both count, so a
|
||||
// person finds an add-on by the name they know it by.
|
||||
const hit = (text) => String(text || '').toLowerCase().includes(q)
|
||||
return tiles.filter(t => hit(titleOf(t)) || hit(t.description) || hit(t.englishTitle) || hit(t.englishDescription) || hit(t.id))
|
||||
}
|
||||
|
||||
// The pinned tiles, in the order the user chose. A pin without a tile (hidden,
|
||||
|
||||
+3
@@ -280,6 +280,9 @@ export function createAddonsCatalogAdapter(api, { pageSize = 24 } = {}) {
|
||||
id: release.id ?? release.addonId,
|
||||
title: release.name || (release.id ?? release.addonId),
|
||||
description: release.description || release.ui?.description || '',
|
||||
// The other languages of the title and description, from the library.
|
||||
titles: release.ui?.titles || {},
|
||||
descriptions: release.ui?.descriptions || {},
|
||||
version: release.version,
|
||||
sourceId: release.sourceId,
|
||||
// The kind of the entry: addon when the library does not say.
|
||||
|
||||
@@ -113,7 +113,7 @@ test('Home addons: disabled failed ready transitions remain pollable and restart
|
||||
test('Home addons: Studio manifest is consumed generically and live Home has no identity branches', () => {
|
||||
const manifest = JSON.parse(readFileSync(new URL('../../../../../addons/studio/manifest.json', import.meta.url)))
|
||||
const ui = manifest.capabilities.ui
|
||||
const commands = ui.homeCommands.map(c => ({...c, page:c.page || ui.mainPage, label:c.label || ui.title, icon:c.icon || 'puzzle'}))
|
||||
const commands = ui.homeCommands.map(c => ({...c, page:c.page || ui.mainPage, label:c.label || (typeof ui.title === 'string' ? ui.title : ui.title.en), icon:c.icon || 'puzzle'}))
|
||||
const installed = app(manifest.id, commands); installed.ui.pages = ui.pages
|
||||
const menu = availableCommands('home', {addonRegistry:snapshot([installed])})
|
||||
assert.ok(menu.some(c => c.cmd === '/studio'))
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
import {
|
||||
resolveTab, visibleTabs, resolveManage, stateChip, appActions, localizedText, toolSummary,
|
||||
shortDigest, updateStatus, formatBytes, rangeText, parseEntryKey, addedByAddon, addedByEntry, screenshotsOf, listState, featuredEntries,
|
||||
attentionOf, sortByAttention, filterByState, hueOf, monogram, versionLabel, skillShadowNotes, skillsOf,
|
||||
attentionOf, sortByAttention, filterByState, hueOf, monogram, versionLabel, skillShadowNotes, skillsOf, localizeEntry, localizeRecord, sortEntriesByTitle,
|
||||
operationOf, upgradingTitles, staleKind, saveLastKnown, loadLastKnown,
|
||||
} from '../../src/components/addons/addonState.js'
|
||||
import { buildTiles, filterTiles } from '../../src/utils/addonTiles.js'
|
||||
import { createAddonsApi, createAddonsCatalogAdapter, AddonsError } from '../../src/utils/addonsApi.js'
|
||||
import { runLifecycle, settleOperation, unavailableWait } from '../../src/components/addons/lifecycleFlow.js'
|
||||
import { createActionBook, effectOutcome } from '../../src/components/addons/actionBook.js'
|
||||
@@ -661,3 +662,71 @@ test('addons: the last list of a tab stands in for a page loaded while the owner
|
||||
saveLastKnown(broken, 'a', [])
|
||||
assert.equal(loadLastKnown(broken, 'a'), null)
|
||||
})
|
||||
|
||||
test('translations: a library entry shows its title and description in the user language and falls back to English per field', () => {
|
||||
const entry = { id: 'tables', key: 's/tables/1', title: 'Tables', description: 'Databases.', titles: { it: 'Tabelle', 'pt-BR': 'Tabelas' }, descriptions: { it: 'Database.' } }
|
||||
const it = localizeEntry(entry, 'it')
|
||||
assert.equal(it.title, 'Tabelle')
|
||||
assert.equal(it.description, 'Database.')
|
||||
assert.equal(it.englishTitle, 'Tables')
|
||||
assert.equal(it.key, 's/tables/1')
|
||||
assert.equal(localizeEntry(entry, 'it-IT').title, 'Tabelle')
|
||||
const pt = localizeEntry(entry, 'pt-BR')
|
||||
assert.equal(pt.title, 'Tabelas')
|
||||
assert.equal(pt.description, 'Databases.')
|
||||
assert.equal(localizeEntry(entry, 'pt').title, 'Tabelas')
|
||||
assert.equal(localizeEntry(entry, 'en').title, 'Tables')
|
||||
assert.equal(localizeEntry(entry, 'de').title, 'Tables')
|
||||
assert.equal(localizeEntry({ id: 'x', title: 'X', description: '' }, 'it').title, 'X')
|
||||
assert.equal(localizeEntry(null, 'it'), null)
|
||||
})
|
||||
|
||||
test('translations: an installed record is named in the user language in dialogs and keeps the English text', () => {
|
||||
const rec = { id: 'tables', title: 'Tables', titles: { en: 'Tables', it: 'Tabelle' }, description: 'Databases.', descriptions: { en: 'Databases.', it: 'Database.' } }
|
||||
const it = localizeRecord(rec, 'it')
|
||||
assert.equal(it.title, 'Tabelle')
|
||||
assert.equal(it.englishTitle, 'Tables')
|
||||
assert.equal(it.description, 'Database.')
|
||||
assert.equal(localizeRecord(rec, 'en').title, 'Tables')
|
||||
assert.equal(localizeRecord({ id: 'x' }, 'it').title, 'x', 'the identifier is the title of last resort')
|
||||
})
|
||||
|
||||
test('translations: the library adapter carries the languages of the entry', async () => {
|
||||
const release = { id: 'tables', name: 'Tables', description: 'Databases.', version: '1.0.0', sourceId: 'main', ui: { title: 'Tables', description: 'Databases.', titles: { it: 'Tabelle' }, descriptions: { it: 'Database.' } } }
|
||||
const api = { dispatch: async () => ({ releases: [release], total: 1 }) }
|
||||
const adapter = createAddonsCatalogAdapter(api)
|
||||
const { entries } = await adapter.search({ text: '', page: 1 })
|
||||
assert.deepEqual(entries[0].titles, { it: 'Tabelle' })
|
||||
assert.deepEqual(entries[0].descriptions, { it: 'Database.' })
|
||||
const plain = createAddonsCatalogAdapter({ dispatch: async () => ({ releases: [{ id: 'x', name: 'X', version: '1', sourceId: 'main' }], total: 1 }) })
|
||||
const out = await plain.search({ text: '', page: 1 })
|
||||
assert.deepEqual(out.entries[0].titles, {})
|
||||
})
|
||||
|
||||
test('translations: the installed list is searched by the translated and by the English name', () => {
|
||||
const apps = [
|
||||
{ id: 'tables', title: 'Tables', titles: { en: 'Tables', it: 'Tabelle' }, description: 'Databases.', descriptions: { it: 'Database di tabelle.' }, enabled: true, state: 'running' },
|
||||
{ id: 'voice', title: 'Voice', titles: { it: 'Voce' }, description: 'Speak.', enabled: true, state: 'running' },
|
||||
]
|
||||
const tiles = buildTiles(apps, [], { language: 'it' })
|
||||
assert.deepEqual(tiles.map(t => t.title), ['Tabelle', 'Voce'])
|
||||
const ids = (q) => filterTiles(tiles, q).map(t => t.id)
|
||||
assert.deepEqual(ids('tabelle'), ['tables'])
|
||||
assert.deepEqual(ids('TABLES'), ['tables'])
|
||||
assert.deepEqual(ids('voce'), ['voice'])
|
||||
assert.deepEqual(ids('voice'), ['voice'])
|
||||
assert.deepEqual(ids('database di'), ['tables'])
|
||||
assert.deepEqual(ids('zzz'), [])
|
||||
assert.equal(filterTiles(tiles, '').length, 2)
|
||||
})
|
||||
|
||||
test('translations: the lists are in the alphabetical order of the user language', () => {
|
||||
const entries = [{ id: 'face', title: 'Face', titles: { it: 'Volto' } }, { id: 'fine-tuning', title: 'Fine-tuning' }, { id: 'discord', title: 'Discord' }, { id: 'tables', title: 'Tables', titles: { it: 'Tabelle' } }]
|
||||
assert.deepEqual(sortEntriesByTitle(entries, 'it').map(e => e.id), ['discord', 'fine-tuning', 'tables', 'face'])
|
||||
assert.deepEqual(sortEntriesByTitle(entries, 'en').map(e => e.id), ['discord', 'face', 'fine-tuning', 'tables'])
|
||||
assert.deepEqual(sortEntriesByTitle(entries, 'xx-not-a-tag').map(e => e.id), ['discord', 'face', 'fine-tuning', 'tables'])
|
||||
assert.deepEqual(sortEntriesByTitle(null, 'it'), [])
|
||||
const apps = entries.map(e => ({ ...e, enabled: true, state: 'running' }))
|
||||
assert.deepEqual(buildTiles(apps, [], { language: 'it' }).map(t => t.id), ['discord', 'fine-tuning', 'tables', 'face'])
|
||||
assert.deepEqual(buildTiles(apps, [], { language: 'en' }).map(t => t.id), ['discord', 'face', 'fine-tuning', 'tables'])
|
||||
})
|
||||
@@ -38,8 +38,13 @@ function providerNames() {
|
||||
// when it cannot be an ordinary word (long or hyphenated).
|
||||
names.add(m.id[0].toUpperCase() + m.id.slice(1))
|
||||
if (m.id.length >= 7 || m.id.includes('-')) names.add(m.id)
|
||||
// The English title is the name. A translation of it is the same name in
|
||||
// the language of the reader and is often an ordinary word there (the
|
||||
// Korean for web, the Chinese for sandbox), so it is not matched; the
|
||||
// translated strings live in the add-on package.
|
||||
const t = c.ui?.title
|
||||
for (const v of typeof t === 'string' ? [t] : Object.values(t || {})) if (typeof v === 'string' && v) names.add(v)
|
||||
const english = typeof t === 'string' ? t : t?.en
|
||||
if (typeof english === 'string' && english) names.add(english)
|
||||
}
|
||||
return [...names]
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user