From 92ade7dbbaf5eb2c891219e4f838e9e1902ac917 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:28:52 +0000 Subject: [PATCH] i18n(client): migrate plural messages to ICU MessageFormat Replace pipe-delimited Vue I18n plural messages with ICU MessageFormat syntax across all locale catalogs and Vue components. Configure custom message formatting and validation to enforce ICU syntax and argument parity. Closes #792 --- client/package.json | 4 +- client/scripts/locale-message-validation.mjs | 262 ++++++++++++++++ .../locale-message-validation.spec.mjs | 113 +++++++ client/scripts/validate-locales.mjs | 27 +- client/src/components/AppHeader.vue | 9 +- client/src/components/IcuCountText.vue | 24 ++ client/src/components/SelectionActionBar.vue | 2 +- .../admin/SharedReadingInsightsPage.vue | 2 +- client/src/features/admin/UsersPage.vue | 4 +- .../annotations/views/AnnotationsHubView.vue | 12 +- .../author/views/AuthorDetailView.vue | 4 +- .../src/features/author/views/AuthorsView.vue | 14 +- .../components/BookDockBulkEditDialog.vue | 2 +- .../components/BookDockFinalizeDialog.vue | 36 +-- .../BookDockSetDestinationDialog.vue | 2 +- .../components/AuthorEnrichmentExpanded.vue | 2 +- .../components/BookMetadataFetchExpanded.vue | 2 +- .../features/book/components/BookListRow.vue | 2 +- .../book/components/BookQuickView.vue | 2 +- .../components/BulkEditMetadataDialog.vue | 2 +- .../book/components/MetadataExportDialog.vue | 4 +- .../book/components/VirtualBookTable.vue | 4 +- .../detail/WriteAndRenameResultPanel.vue | 2 +- .../detail/tabs/AddBookFileModal.vue | 6 +- .../detail/tabs/EditMetadataTab.vue | 12 +- .../book/components/detail/tabs/FilesTab.vue | 4 +- .../components/detail/tabs/HighlightsTab.vue | 6 +- .../detail/tabs/MetadataDiffPanel.vue | 2 +- .../components/detail/tabs/ReadingLogHero.vue | 4 +- .../table/BookTableCollapsedSeriesCell.vue | 2 +- .../components/AddToCollectionSheet.vue | 12 +- .../__tests__/AddToCollectionSheet.spec.ts | 4 +- .../components/IconUploadDialog.vue | 4 +- .../widgets/DiversityScoreWidget.vue | 2 +- .../components/widgets/LongWaitWidget.vue | 2 +- .../components/widgets/ReadingDnaWidget.vue | 2 +- .../widgets/ReadingRhythmWidget.vue | 2 +- .../widgets/ReadingStreakWidget.vue | 6 +- .../features/email/components/GroupsTab.vue | 2 +- .../email/components/SendBookDialog.vue | 6 +- .../__tests__/SendBookDialog.spec.ts | 2 +- .../components/HardcoverImportReviewModal.vue | 4 +- .../components/HardcoverImportStatus.vue | 12 +- .../components/HardcoverSyncProgress.vue | 4 +- .../library/components/BookUploadModal.vue | 6 +- .../library/components/FolderPickerModal.vue | 2 +- .../components/MetadataScoreBreakdown.vue | 2 +- .../migration/components/MigrationModal.vue | 2 +- .../epub/components/ReaderSearchPanel.vue | 2 +- .../scanner/components/ScanProgressBar.vue | 2 +- .../series/views/SeriesDetailView.vue | 2 +- .../settings/AppearanceIconsSettings.vue | 12 +- .../src/features/settings/FontsSettings.vue | 4 +- client/src/features/settings/KoboSettings.vue | 30 +- .../features/settings/KoreaderSettings.vue | 12 +- .../features/settings/LibrariesSettings.vue | 8 +- .../features/settings/MagicLinksSettings.vue | 2 +- .../features/settings/MaintenanceSettings.vue | 4 +- .../features/settings/MigrationSettings.vue | 2 +- .../settings/PrivacySharingSettings.vue | 2 +- .../components/GlobalAutoFetchConfig.spec.ts | 2 +- .../components/GlobalAutoFetchConfig.vue | 6 +- .../components/LibraryAutoFetchConfig.vue | 6 +- .../CustomMetadataSettings.vue | 4 +- .../MetadataGenreBlocklistSettings.vue | 2 +- .../components/LibraryPreferencePanel.vue | 2 +- .../components/SmartScopeEditorPanel.vue | 3 +- .../statistics/components/ChartCard.vue | 2 +- .../components/DeleteDuplicateBooksDialog.vue | 4 +- .../views/BookDuplicatesView.vue | 2 +- .../components/BulkRenameConfirmDialog.vue | 11 +- .../bulk-rename/views/BulkRenameView.vue | 4 +- .../tools/composables/useBookDuplicates.ts | 2 +- .../components/BrowseMergeModal.vue | 2 +- .../components/BulkDeleteModal.vue | 9 +- .../components/DuplicateClusterCard.vue | 6 +- .../components/EntityBrowseTable.vue | 4 +- .../views/EntityManagerView.vue | 2 +- .../src/features/whats-new/WhatsNewDialog.vue | 2 +- client/src/i18n/icu.spec.ts | 230 ++++++++++++++ client/src/i18n/icu.ts | 104 +++++++ client/src/i18n/index.spec.ts | 29 -- client/src/i18n/index.ts | 29 +- client/src/locales/de.json | 291 +++++++++--------- client/src/locales/en.json | 291 +++++++++--------- client/src/locales/nl.json | 291 +++++++++--------- client/src/locales/pt.json | 291 +++++++++--------- client/src/locales/sl.json | 291 +++++++++--------- client/src/views/BookDockView.vue | 12 +- client/src/views/CollectionView.vue | 2 +- client/vite.config.ts | 9 +- docs/LOCALIZATION.md | 35 ++- pnpm-lock.yaml | 221 +++---------- 93 files changed, 1709 insertions(+), 1223 deletions(-) create mode 100644 client/scripts/locale-message-validation.mjs create mode 100644 client/scripts/locale-message-validation.spec.mjs create mode 100644 client/src/components/IcuCountText.vue create mode 100644 client/src/i18n/icu.spec.ts create mode 100644 client/src/i18n/icu.ts delete mode 100644 client/src/i18n/index.spec.ts diff --git a/client/package.json b/client/package.json index c91cd722a..098c77de6 100644 --- a/client/package.json +++ b/client/package.json @@ -59,6 +59,7 @@ "@embedpdf/plugin-zoom": "2.14.4", "@fontsource-variable/fraunces": "^5.3.0", "@fontsource-variable/inter": "^5.3.0", + "@formatjs/icu-messageformat-parser": "^3.5.15", "@lucide/vue": "1.24.0", "@tailwindcss/vite": "^4.3.3", "@tanstack/vue-table": "^8.21.3", @@ -77,6 +78,7 @@ "driver.js": "^1.8.0", "echarts": "^6.1.0", "howler": "^2.2.4", + "intl-messageformat": "^11.2.12", "pinia": "^3.0.4", "reka-ui": "^2.10.1", "socket.io-client": "^4.8.3", @@ -92,7 +94,7 @@ "vue-virtual-scroller": "^3.0.4" }, "devDependencies": { - "@intlify/unplugin-vue-i18n": "^11.2.4", + "@intlify/message-compiler": "^11.4.8", "@tsconfig/node24": "^24.0.4", "@types/canvas-confetti": "^1.9.0", "@types/jsdom": "^28.0.3", diff --git a/client/scripts/locale-message-validation.mjs b/client/scripts/locale-message-validation.mjs new file mode 100644 index 000000000..41627b768 --- /dev/null +++ b/client/scripts/locale-message-validation.mjs @@ -0,0 +1,262 @@ +import { parse, TYPE } from '@formatjs/icu-messageformat-parser' +import { baseCompile } from '@intlify/message-compiler' + +const ICU_PLURAL_PATTERN = /\{\s*[\w.-]+\s*,\s*(?:plural|selectordinal)\s*,/ + +export function isIcuPluralMessage(message) { + return ICU_PLURAL_PATTERN.test(message) +} + +function argumentSignature(element) { + const type = { + [TYPE.argument]: 'argument', + [TYPE.number]: 'number', + [TYPE.date]: 'date', + [TYPE.time]: 'time', + [TYPE.select]: 'select', + [TYPE.plural]: element.pluralType, + }[element.type] + const style = 'style' in element && element.style !== null ? `:${JSON.stringify(element.style)}` : '' + return `${element.value}:${type}${style}` +} + +function optionHasContent(elements) { + return elements.some((element) => { + if (element.type === TYPE.literal) return element.value.trim().length > 0 + if (element.type === TYPE.tag) return optionHasContent(element.children) + return true + }) +} + +function walkOptions(element, analysis) { + for (const [selector, option] of Object.entries(element.options)) { + if (!optionHasContent(option.value)) { + analysis.emptyOptions.push({ + argument: element.value, + selector, + }) + } + walkElements(option.value, analysis) + } +} + +function walkElements(elements, analysis) { + for (const element of elements) { + if ( + element.type === TYPE.argument || + element.type === TYPE.number || + element.type === TYPE.date || + element.type === TYPE.time || + element.type === TYPE.select || + element.type === TYPE.plural + ) { + analysis.arguments.add(element.value) + analysis.argumentSignatures.add(argumentSignature(element)) + } + + if (element.type === TYPE.select) { + analysis.selects.push({ + argument: element.value, + selectors: Object.keys(element.options), + }) + walkOptions(element, analysis) + } + + if (element.type === TYPE.plural) { + analysis.plurals.push({ + argument: element.value, + offset: element.offset, + type: element.pluralType, + selectors: Object.keys(element.options), + }) + walkOptions(element, analysis) + } + + if (element.type === TYPE.tag) walkElements(element.children, analysis) + } +} + +export function analyzeIcuMessage(message) { + const analysis = { + arguments: new Set(), + argumentSignatures: new Set(), + emptyOptions: [], + plurals: [], + selects: [], + } + walkElements(parse(message), analysis) + + return { + arguments: [...analysis.arguments].sort(), + argumentSignatures: [...analysis.argumentSignatures].sort(), + emptyOptions: analysis.emptyOptions, + plurals: analysis.plurals.map((plural) => ({ + ...plural, + selectors: [...plural.selectors].sort(), + })), + selects: analysis.selects.map((select) => ({ + ...select, + selectors: [...select.selectors].sort(), + })), + } +} + +function pluralCategories(locale, type) { + return new Intl.PluralRules(locale, { type }).resolvedOptions().pluralCategories +} + +function exactSelectors(selectors) { + return selectors.filter((selector) => selector.startsWith('=')) +} + +function analyzeVueMessage(message) { + const errors = [] + const { ast } = baseCompile(message, { + onError: (error) => errors.push(error.message), + }) + const placeholders = new Set() + + function walk(value) { + if (Array.isArray(value)) { + for (const child of value) walk(child) + return + } + if (!value || typeof value !== 'object') return + if (value.type === 4) placeholders.add(`named:${value.key}`) + if (value.type === 5) placeholders.add(`list:${value.index}`) + for (const [key, child] of Object.entries(value)) { + if (key !== 'loc') walk(child) + } + } + + walk(ast.body) + return { + errors, + isPlural: ast.body.type === 1, + placeholders: [...placeholders].sort(), + } +} + +function pluralOptionPounds(elements) { + let totals = [0] + + const combine = (other) => { + totals = [...new Set(totals.flatMap((total) => other.map((addition) => total + addition)))] + } + + for (const element of elements) { + if (element.type === TYPE.pound) combine([1]) + else if (element.type === TYPE.tag) combine(pluralOptionPounds(element.children)) + else if (element.type === TYPE.select || element.type === TYPE.plural) { + combine(Object.values(element.options).flatMap((option) => pluralOptionPounds(option.value))) + } + } + + return totals +} + +export function validateSlotCountMessage({ key, locale, message }) { + if (!isIcuPluralMessage(message)) return [`${locale}: ICU plural syntax required for slot count message ${key}`] + + let totals + try { + totals = pluralOptionPounds(parse(message)) + } catch { + return [] + } + + return totals.every((total) => total === 1) ? [] : [`${locale}: slot count message ${key} must render exactly one # in every branch`] +} + +export function validateLocaleMessage({ key, locale, message, referenceMessage }) { + const errors = [] + const referenceIsIcu = isIcuPluralMessage(referenceMessage) + const messageIsIcu = isIcuPluralMessage(message) + + if (!referenceIsIcu) { + const referenceAnalysis = analyzeVueMessage(referenceMessage) + const messageAnalysis = analyzeVueMessage(message) + + if (messageIsIcu) errors.push(`${locale}: unexpected ICU plural syntax in ${key}`) + if (referenceAnalysis.isPlural) errors.push(`${locale}: legacy plural branches are not allowed for ${key}`) + if (messageAnalysis.isPlural && message !== referenceMessage) { + errors.push(`${locale}: legacy plural branches are not allowed for ${key}`) + } + + if (messageAnalysis.errors.length > 0) { + errors.push(`${locale}: invalid Vue I18n syntax in ${key}: ${messageAnalysis.errors.join('; ')}`) + return errors + } + + if (messageAnalysis.placeholders.join(',') !== referenceAnalysis.placeholders.join(',')) { + errors.push(`${locale}: placeholders differ for ${key}`) + } + + return errors + } + + if (message.includes(' | ')) errors.push(`${locale}: legacy plural branches are not allowed for ICU message ${key}`) + if (!messageIsIcu) { + errors.push(`${locale}: ICU plural syntax required for ${key}`) + return errors + } + + let referenceAnalysis + let messageAnalysis + try { + referenceAnalysis = analyzeIcuMessage(referenceMessage) + } catch (error) { + errors.push(`en: invalid ICU syntax in ${key}: ${error.message}`) + return errors + } + try { + messageAnalysis = analyzeIcuMessage(message) + } catch (error) { + errors.push(`${locale}: invalid ICU syntax in ${key}: ${error.message}`) + return errors + } + + const argumentsMatch = messageAnalysis.arguments.join(',') === referenceAnalysis.arguments.join(',') + if (!argumentsMatch) { + errors.push(`${locale}: ICU arguments differ for ${key}`) + } + if (argumentsMatch && messageAnalysis.argumentSignatures.join(',') !== referenceAnalysis.argumentSignatures.join(',')) { + errors.push(`${locale}: ICU argument types differ for ${key}`) + } + for (const option of messageAnalysis.emptyOptions) { + errors.push(`${locale}: empty ICU option ${option.selector} for ${option.argument} in ${key}`) + } + const selectSignatures = (analysis) => analysis.selects.map((select) => `${select.argument}:${select.selectors.join(',')}`).sort() + if (selectSignatures(messageAnalysis).join('|') !== selectSignatures(referenceAnalysis).join('|')) { + errors.push(`${locale}: ICU select expressions differ for ${key}`) + } + if (messageAnalysis.plurals.length !== referenceAnalysis.plurals.length) { + errors.push(`${locale}: ICU plural expressions differ for ${key}`) + return errors + } + + const remainingPlurals = [...messageAnalysis.plurals] + for (const referencePlural of referenceAnalysis.plurals) { + const matchingIndex = remainingPlurals.findIndex((plural) => plural.argument === referencePlural.argument) + const messagePlural = remainingPlurals.splice(matchingIndex === -1 ? 0 : matchingIndex, 1)[0] + if ( + messagePlural.argument !== referencePlural.argument || + messagePlural.offset !== referencePlural.offset || + messagePlural.type !== referencePlural.type + ) { + errors.push(`${locale}: ICU plural expressions differ for ${key}`) + continue + } + + const selectors = new Set(messagePlural.selectors) + for (const selector of exactSelectors(referencePlural.selectors)) { + if (!selectors.has(selector)) errors.push(`${locale}: ICU selector ${selector} missing for ${key}`) + } + const requiredCategories = new Set([...pluralCategories(locale, messagePlural.type), 'other']) + for (const category of requiredCategories) { + if (!selectors.has(category)) errors.push(`${locale}: ICU plural category ${category} missing for ${key}`) + } + } + + return errors +} diff --git a/client/scripts/locale-message-validation.spec.mjs b/client/scripts/locale-message-validation.spec.mjs new file mode 100644 index 000000000..334cf7d41 --- /dev/null +++ b/client/scripts/locale-message-validation.spec.mjs @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { analyzeIcuMessage, isIcuPluralMessage, validateLocaleMessage, validateSlotCountMessage } from './locale-message-validation.mjs' + +const validate = (locale, referenceMessage, message) => + validateLocaleMessage({ + key: 'books.count', + locale, + message, + referenceMessage, + }) + +describe('locale message validation', () => { + it('analyzes nested ICU arguments and plural selectors', () => { + const message = '{count, plural, =0 {No books for {name}} one {One book} other {# books}}' + + expect(isIcuPluralMessage(message)).toBe(true) + expect(analyzeIcuMessage(message)).toEqual({ + arguments: ['count', 'name'], + argumentSignatures: ['count:cardinal', 'name:argument'], + emptyOptions: [], + plurals: [ + { + argument: 'count', + offset: 0, + type: 'cardinal', + selectors: ['=0', 'one', 'other'], + }, + ], + selects: [], + }) + }) + + it('rejects malformed ordinary Vue I18n messages', () => { + expect(validate('de', 'Hello, {name}!', 'Hallo, {{name}}!')).toEqual(['de: invalid Vue I18n syntax in books.count: Not allowed nest placeholder']) + }) + + it('rejects legacy plural syntax', () => { + expect(validate('de', 'No books | One book | {count} books', 'Keine Bücher | Ein Buch | {count} Bücher')).toContain( + 'de: legacy plural branches are not allowed for books.count', + ) + expect(validate('de', 'No books|One book|{count} books', 'Keine Bücher|Ein Buch|{count} Bücher')).toContain( + 'de: legacy plural branches are not allowed for books.count', + ) + }) + + it('requires matching ICU arguments and exact selectors', () => { + const reference = '{count, plural, =0 {No books for {name}} one {One book for {name}} other {# books for {name}}}' + const message = '{count, plural, one {Ein Buch} other {# Bücher}}' + + expect(validate('de', reference, message)).toEqual(['de: ICU arguments differ for books.count', 'de: ICU selector =0 missing for books.count']) + }) + + it('requires matching ICU argument types and styles', () => { + const reference = '{count, plural, one {One book for {name}} other {# books for {name}}}' + const changedType = '{count, plural, one {Ein Buch für {name, number}} other {# Bücher für {name, number}}}' + + expect(validate('de', reference, changedType)).toContain('de: ICU argument types differ for books.count') + + const styledReference = '{count, plural, one {{amount, number, integer} book} other {{amount, number, integer} books}}' + const changedStyle = '{count, plural, one {{amount, number, percent} Buch} other {{amount, number, percent} Bücher}}' + + expect(validate('de', styledReference, changedStyle)).toContain('de: ICU argument types differ for books.count') + }) + + it('rejects empty ICU options', () => { + const reference = '{count, plural, one {One book} other {# books}}' + const message = '{count, plural, one {} other {# Bücher}}' + + expect(validate('de', reference, message)).toContain('de: empty ICU option one for count in books.count') + }) + + it('requires matching plural offsets', () => { + const reference = '{count, plural, one {One book} other {# books}}' + const message = '{count, plural, offset:1 one {Ein Buch} other {# Bücher}}' + + expect(validate('de', reference, message)).toContain('de: ICU plural expressions differ for books.count') + }) + + it('requires locale-specific cardinal categories', () => { + const reference = '{count, plural, one {# book} other {# books}}' + const message = '{count, plural, one {# knjiga} other {# knjig}}' + + expect(validate('sl', reference, message)).toEqual([ + 'sl: ICU plural category two missing for books.count', + 'sl: ICU plural category few missing for books.count', + ]) + }) + + it('reports invalid ICU and rejects legacy branches for migrated keys', () => { + const reference = '{count, plural, one {One book} other {# books}}' + + expect(validate('de', reference, '{count, plural, one {Ein Buch} other {# Bücher}')).toEqual([ + 'de: invalid ICU syntax in books.count: EXPECT_ARGUMENT_CLOSING_BRACE', + ]) + expect(validate('de', reference, 'Kein Buch | Ein Buch | {count} Bücher')).toEqual([ + 'de: legacy plural branches are not allowed for ICU message books.count', + 'de: ICU plural syntax required for books.count', + ]) + }) + + it('requires slot count messages to render exactly one count', () => { + const slotCount = (message) => validateSlotCountMessage({ key: 'books.count', locale: 'de', message }) + + expect(slotCount('{count, plural, one {# Buch} other {# Bücher}}')).toEqual([]) + expect(slotCount('{count, plural, one {Ein Buch} other {# Bücher}}')).toEqual([ + 'de: slot count message books.count must render exactly one # in every branch', + ]) + expect(slotCount('{count, plural, one {# von # Büchern} other {# Bücher}}')).toEqual([ + 'de: slot count message books.count must render exactly one # in every branch', + ]) + expect(slotCount('Keine Bücher')).toEqual(['de: ICU plural syntax required for slot count message books.count']) + }) +}) diff --git a/client/scripts/validate-locales.mjs b/client/scripts/validate-locales.mjs index e022a111a..eff1e1058 100644 --- a/client/scripts/validate-locales.mjs +++ b/client/scripts/validate-locales.mjs @@ -1,6 +1,7 @@ import { readdir, readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import path from 'node:path' +import { validateLocaleMessage, validateSlotCountMessage } from './locale-message-validation.mjs' const clientRoot = fileURLToPath(new URL('..', import.meta.url)) const localesDirectory = path.join(clientRoot, 'src/locales') @@ -16,10 +17,6 @@ function flatten(value, prefix = '', output = new Map()) { return output } -function placeholders(message) { - return [...new Set(message.match(/\{[^{}]+\}/g) ?? [])].sort() -} - async function sourceFiles(directory) { const entries = await readdir(directory, { withFileTypes: true }) const files = await Promise.all( @@ -39,6 +36,7 @@ async function sourceMessageKeys() { const sourceDirectory = path.join(clientRoot, 'src') const files = await sourceFiles(sourceDirectory) const keys = new Set() + const slotCountKeys = new Set() for (const file of files) { const source = await readFile(file, 'utf8') @@ -48,9 +46,13 @@ async function sourceMessageKeys() { for (const match of source.matchAll(/]*\bkeypath\s*=\s*(['"])([A-Za-z0-9_.-]+)\1/g)) { keys.add(match[2]) } + for (const match of source.matchAll(/]*\bkeypath\s*=\s*(['"])([A-Za-z0-9_.-]+)\1/g)) { + keys.add(match[2]) + slotCountKeys.add(match[2]) + } } - return keys + return { keys, slotCountKeys } } const localeTypes = await readFile(localeTypesPath, 'utf8') @@ -74,7 +76,8 @@ const reference = catalogs.get('en') if (!reference) throw new Error('English reference catalog is required') const errors = [] -for (const key of await sourceMessageKeys()) { +const { keys: referencedKeys, slotCountKeys } = await sourceMessageKeys() +for (const key of referencedKeys) { if (!reference.has(key)) errors.push(`en: missing key referenced in source ${key}`) } @@ -88,16 +91,8 @@ for (const [locale, catalog] of catalogs) { if (message.length === 0) errors.push(`${locale}: empty message ${key}`) if (message.includes('\u2014')) errors.push(`${locale}: Unicode em dash is not allowed in ${key}`) if (/<[^>]+>/.test(message)) errors.push(`${locale}: HTML is not allowed in ${key}`) - if (placeholders(message).join(',') !== placeholders(referenceMessage).join(',')) { - errors.push(`${locale}: placeholders differ for ${key}`) - } - const referenceIsPlural = referenceMessage.includes(' | ') - const messageIsPlural = message.includes(' | ') - if (referenceIsPlural && !messageIsPlural) errors.push(`${locale}: plural branches missing for ${key}`) - if (!referenceIsPlural && messageIsPlural) errors.push(`${locale}: unexpected plural branches in ${key}`) - if (message.includes(' | ') && message.split(' | ').some((branch) => branch.trim().length === 0)) { - errors.push(`${locale}: empty plural branch in ${key}`) - } + errors.push(...validateLocaleMessage({ key, locale, message, referenceMessage })) + if (slotCountKeys.has(key)) errors.push(...validateSlotCountMessage({ key, locale, message })) } for (const key of catalog.keys()) { if (!reference.has(key)) errors.push(`${locale}: unexpected key ${key}`) diff --git a/client/src/components/AppHeader.vue b/client/src/components/AppHeader.vue index 31dffebf5..2ea27fdec 100644 --- a/client/src/components/AppHeader.vue +++ b/client/src/components/AppHeader.vue @@ -24,7 +24,6 @@ import { } from '@lucide/vue' import { useRouter, useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' -import { formatNumber } from '@/i18n/formatters' import { toast } from 'vue-sonner' import { SidebarTrigger } from '@/components/ui/sidebar' import { Separator } from '@/components/ui/separator' @@ -165,9 +164,7 @@ const globalSearchLoadMoreLabel = computed(() => ? t('components.appHeader.loadingMore') : t('components.appHeader.loadMore', { loaded: globalResults.value.length, total: globalSearchTotal.value }), ) -const globalSearchAllLoadedLabel = computed(() => - t('components.appHeader.allMatchesShown', { count: formatNumber(globalSearchTotal.value) }, globalSearchTotal.value), -) +const globalSearchAllLoadedLabel = computed(() => t('components.appHeader.allMatchesShown', { count: globalSearchTotal.value })) const globalSearchVirtualHeightStyle = computed(() => ({ height: `${globalResults.value.length * GLOBAL_SEARCH_ROW_HEIGHT}px`, })) @@ -301,8 +298,8 @@ onMounted(() => { const stopLibraryUploadListener = onLibraryUploadCompleted((event) => { if (event.uploadedCount === 0 && event.failedCount === 0) return - const uploadedLabel = t('components.appHeader.bookCount', { count: event.uploadedCount }, event.uploadedCount) - const failedLabel = t('components.appHeader.fileCount', { count: event.failedCount }, event.failedCount) + const uploadedLabel = t('components.appHeader.bookCount', { count: event.uploadedCount }) + const failedLabel = t('components.appHeader.fileCount', { count: event.failedCount }) if (event.failedCount === 0) { toast.success(t('components.appHeader.uploadedToast', { uploaded: uploadedLabel })) diff --git a/client/src/components/IcuCountText.vue b/client/src/components/IcuCountText.vue new file mode 100644 index 000000000..c4354901a --- /dev/null +++ b/client/src/components/IcuCountText.vue @@ -0,0 +1,24 @@ + + + diff --git a/client/src/components/SelectionActionBar.vue b/client/src/components/SelectionActionBar.vue index d39d0d4f7..028770fa6 100644 --- a/client/src/components/SelectionActionBar.vue +++ b/client/src/components/SelectionActionBar.vue @@ -541,7 +541,7 @@ watch(