diff --git a/.claude/skills/verify-debatelab/recipes/localization.spec.ts b/.claude/skills/verify-debatelab/recipes/localization.spec.ts index 54c604712..40175bdad 100644 --- a/.claude/skills/verify-debatelab/recipes/localization.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/localization.spec.ts @@ -94,7 +94,7 @@ test("loc-switcher: sign-in and not-found pages carry the language switcher", as await evidence.shot("01-login-en"); await chooseLanguage(page, "hu"); await expect(page.getByRole("heading", { level: 1, name: hu.login.title })).toBeVisible(); - // "Bejelentkezés a DebateLab-ba" already names the app, so it gets no suffix. + // "Bejelentkezés a DebateLabba" already names the app, so it gets no suffix. await expect(page).toHaveTitle(hu.login.title); await evidence.shot("02-login-hu"); diff --git a/agentic/pages.e2e.ts b/agentic/pages.e2e.ts index 446531a29..9be9bf0b3 100644 --- a/agentic/pages.e2e.ts +++ b/agentic/pages.e2e.ts @@ -30,7 +30,7 @@ const PUBLIC_PAGES = ["/", "/login", "/terms", "/privacy", "/expocit/sources"]; const NAMES: Record = { en: "English", es: "Spanish", hu: "Hungarian" }; const EXEMPT = - "Product names such as DebateLab and ExpoCit, AI model names (Claude Sonnet 5), plan names (Pro), debate format codes, language names offered as choices (English, Español, Magyar), and user content do not count. Technical loanwords the app uses on purpose, such as email, chat, prompt and token, count as translated."; + "Product names such as DebateLab and ExpoCit, AI model names (Claude Sonnet 5), plan names (Pro), debate format codes and the competition format names kept in English in every language (Public Forum, Lincoln-Douglas, Policy, British Parliamentary, World Schools, Asian Parliamentary), language names offered as choices (English, Español, Magyar), and user content do not count. Technical loanwords the app uses on purpose, such as email, chat, prompt and token, count as translated."; async function checkPage(browser: Browser, agent: Agent, language: Language) { await expect(browser.locator("html")).toHaveAttribute("lang", language); diff --git a/docs/standards/ui.md b/docs/standards/ui.md index 77633e243..17001bb41 100644 --- a/docs/standards/ui.md +++ b/docs/standards/ui.md @@ -6,7 +6,26 @@ Part of the [coding standards](../../CODING_STANDARDS.md). [docs/architecture.md - Every user-facing string, including toasts, errors, and accessible names, goes through `t()`. `src/test/i18n-keys.test.ts` fails when a literal key is missing from the catalogs or a `count` call lacks its plural forms. It cannot see dynamic keys (``t(`nav.${key}`)``) or raw values rendered as text, so check those by hand: an English word in a Hungarian screen is the usual sign ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004217), [#43](https://github.com/Programcode/DebateLab/pull/43#discussion_r4048412531)). - Store a translation key or error kind in state and translate at render time. English strings stored in state stay English after a reload ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641290)). -- Use the localized feature name everywhere in a catalog: "Interrogatorio", "Keresztkérdezés" ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641268), [#13 review](https://github.com/Programcode/DebateLab/pull/13#issuecomment-5477866033)). +- Use the localized feature name everywhere in a catalog: "Interrogatorio", "Keresztkérdezés" ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641268), [#13 review](https://github.com/Programcode/DebateLab/pull/13#issuecomment-5477866033)). The glossary below is the one name per language for each tool, role, and format. +- A count goes into the string as `count`, so i18next picks the plural form: `t('libraryPage.sourceCount', { count })`, never `` `${n} ${t('sources')}` ``. Format numbers and dates with `src/lib/date-format.ts`, which follows the interface language. +- `src/test/i18n-untranslated.test.ts` fails when a Spanish or Hungarian value is identical to English. When that is intended, such as a product or format name, add it to the test's list under a reason. + +### Glossary + +| Concept | English | Spanish | Hungarian | +| --- | --- | --- | --- | +| Crossfire (tool and saved round) | Crossfire | Interrogatorio | Keresztkérdezés | +| Sparring (tool and saved round) | Sparring | Sparring | Vitapárbaj | +| Timer | Timer | Cronómetro | Időmérő | +| Case | Case | Caso | Eset (Vitaeset in titles) | +| Ruthless (difficulty, intensity) | Ruthless | Despiadado | Kíméletlen | +| School format | School 4-6-6-3 | Formato escolar 4-6-6-3 | Iskolai 4-6-6-3 | +| School-format roles (the Timer spells out R1 and R2) | Introductor, R1, R2, Conclusor | Introductor, R1, R2, Conclusor | Bevezető, R1, R2, Záró | +| Sides in a Sparring turn | Pro, Con | a favor, en contra | Pro, Kontra | +| Competition formats | Public Forum, Lincoln-Douglas, Policy, British Parliamentary, World Schools, Asian Parliamentary | same as English | same as English | +| Load (button) / Loading | Load / Loading | Cargar / Cargando | Megnyitás / Betöltés | +| AI | AI | IA | MI | +| DebateLab with a suffix | | | DebateLabba, DebateLabban, DebateLabhoz, DebateLabot (no hyphen, back vowels) | ## Accessible names diff --git a/e2e/first-run.spec.ts b/e2e/first-run.spec.ts index 9e8bf45f5..bb0bd5bd2 100644 --- a/e2e/first-run.spec.ts +++ b/e2e/first-run.spec.ts @@ -24,7 +24,7 @@ const copy = { gateMotion: 'This House would abolish zoos', }, hu: { - welcome: 'Üdvözlünk a DebateLab-ban!', + welcome: 'Üdvözlünk a DebateLabban!', firstDebate: 'Itt indíthatod el az első vitádat.', startDebate: 'Vita indítása', motionField: 'Tétel', diff --git a/e2e/full-stack/first-run.spec.ts b/e2e/full-stack/first-run.spec.ts index 509e67a93..e7e54ff88 100644 --- a/e2e/full-stack/first-run.spec.ts +++ b/e2e/full-stack/first-run.spec.ts @@ -29,7 +29,7 @@ const copy = { demoTab: "Demókód", demoCode: "Demókód", signIn: "Bejelentkezés demókóddal", - welcome: "Üdvözlünk a DebateLab-ban!", + welcome: "Üdvözlünk a DebateLabban!", firstDebate: "Itt indíthatod el az első vitádat.", startDebate: "Vita indítása", motionField: "Tétel", diff --git a/e2e/hungarian-parity.spec.ts b/e2e/hungarian-parity.spec.ts index 8c8359a6f..dbf95e3d0 100644 --- a/e2e/hungarian-parity.spec.ts +++ b/e2e/hungarian-parity.spec.ts @@ -46,7 +46,7 @@ test.describe('Hungarian parity', () => { await expectHungarianDocument(page); for (const [path, heading] of [ - ['/login', 'Bejelentkezés a DebateLab-ba'], + ['/login', 'Bejelentkezés a DebateLabba'], ['/terms', 'Felhasználási feltételek'], ['/privacy', 'Adatvédelmi irányelvek'], ['/expocit/sources', 'Források'], @@ -76,7 +76,7 @@ test.describe('Hungarian parity', () => { await expectHungarianDocument(page); await expect(page.getByRole('heading', { name: 'Felkészülés' })).toBeVisible({ timeout: 15_000 }); - await expect(page.locator('.react-joyride__tooltip')).toContainText('Üdvözlünk a DebateLab-ban!', { timeout: 15_000 }); + await expect(page.locator('.react-joyride__tooltip')).toContainText('Üdvözlünk a DebateLabban!', { timeout: 15_000 }); await expect(page.locator('[data-test-id="button-primary"]')).toContainText('Következő'); await expect(page.locator('[data-test-id="button-skip"]')).toContainText('Túra kihagyása'); await page.locator('[data-test-id="button-skip"]').click(); @@ -139,7 +139,7 @@ test.describe('Hungarian parity', () => { await expect(page.getByRole('alert').filter({ hasText: 'Adj meg egy címet.' })).toBeVisible(); await page.goto('/app/flowchart', { waitUntil: 'domcontentloaded' }); - await page.getByRole('button', { name: 'Betöltés' }).click(); + await page.getByRole('button', { name: 'Megnyitás', exact: true }).click(); await expect(page.getByRole('dialog', { name: 'Mentett folyamatábrák' }).getByRole('button', { name: 'Bezárás' })).toBeVisible(); await page.keyboard.press('Escape'); // Deleting a node raises a toast, whose close button is localized too. @@ -150,7 +150,7 @@ test.describe('Hungarian parity', () => { await expect(page.locator('[data-sonner-toast] [data-close-button]')).toHaveAttribute('aria-label', 'Bezárás'); await page.goto('/app/timer', { waitUntil: 'domcontentloaded' }); - await expect(page.getByRole('timer', { name: 'Időmérő kijelző' })).toBeVisible(); + await expect(page.getByRole('timer', { name: 'Időmérő-kijelző' })).toBeVisible(); expect(errors, errors.join('\n')).toEqual([]); }); }); diff --git a/e2e/localization.spec.ts b/e2e/localization.spec.ts index d6ea3c3f0..5fa792000 100644 --- a/e2e/localization.spec.ts +++ b/e2e/localization.spec.ts @@ -104,7 +104,7 @@ test('sign-in and not-found pages carry the language switcher', async ({ page }) await shot(page, '01-login-en'); await choose(page, 'hu'); await expect(page.getByRole('heading', { level: 1, name: hu.login.title })).toBeVisible(); - // "Bejelentkezés a DebateLab-ba" already names the app, so it gets no suffix. + // "Bejelentkezés a DebateLabba" already names the app, so it gets no suffix. await expect(page).toHaveTitle(hu.login.title); await shot(page, '02-login-hu'); diff --git a/src/components/features/ask/AskChat.tsx b/src/components/features/ask/AskChat.tsx index 517b99b30..ec052671a 100644 --- a/src/components/features/ask/AskChat.tsx +++ b/src/components/features/ask/AskChat.tsx @@ -13,7 +13,7 @@ import { cn } from '@/lib/utils'; import { useSpeechToText } from '@/hooks/use-speech-to-text'; import { useToast } from '@/hooks/use-toast'; import { validateFileUpload, MAX_FILE_COUNT, formatFileSize } from '@/lib/validation/file'; -import { formatEuropeanDate, formatEuropeanTime } from '@/lib/date-format'; +import { formatDecimal, formatEuropeanDate, formatEuropeanTime } from '@/lib/date-format'; import { ModelSelector } from '@/components/ModelSelector'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; @@ -311,7 +311,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, onSave }: { m parts.push(message.output.provider); } if (message.role === 'assistant' && message.output?.durationMs) { - parts.push(`${(message.output.durationMs / 1000).toFixed(1)}s`); + parts.push(t('askChat.elapsed', { seconds: formatDecimal(message.output.durationMs / 1000, 1) })); } return parts.join(' • '); } catch (e) { diff --git a/src/components/features/attack/AttackResults.tsx b/src/components/features/attack/AttackResults.tsx index 884e48ee2..4ff5d2aaa 100644 --- a/src/components/features/attack/AttackResults.tsx +++ b/src/components/features/attack/AttackResults.tsx @@ -208,15 +208,15 @@ export function AttackResults({ result, discoveredSources, onDiscuss, onPractice
- {criticalCount} {t('attackResults.critical')} + {t('attackResults.criticalCount', { count: criticalCount })}
- {majorCount} {t('attackResults.major')} + {t('attackResults.majorCount', { count: majorCount })}
- {minorCount} {t('attackResults.minor')} + {t('attackResults.minorCount', { count: minorCount })}
diff --git a/src/components/features/debate/CurrentDebateMenu.tsx b/src/components/features/debate/CurrentDebateMenu.tsx index ac7d1b426..e660cd32a 100644 --- a/src/components/features/debate/CurrentDebateMenu.tsx +++ b/src/components/features/debate/CurrentDebateMenu.tsx @@ -7,7 +7,7 @@ import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/compon import { formatRelativeDate } from '@/lib/date-format'; import { cn } from '@/lib/utils'; import { useCurrentDebate, useDebateStore } from '@/lib/stores/debate-store'; -import { useDebateCounts } from './CurrentDebateStrip'; +import { debateCountsLabel, useDebateCounts } from './CurrentDebateStrip'; import { navSectionFor } from '@/components/layout/nav-sections'; /** @@ -81,7 +81,7 @@ export function CurrentDebateMenu({ variant }: { variant: 'desktop' | 'mobile' } {t('debate.lastActivity', { when: formatRelativeDate(debate.lastActivityAt), defaultValue: 'Last activity {{when}}' })}

- {t('debate.counts', { sources: counts.sources, active: counts.activeSources, outputs: counts.outputs, defaultValue: '{{sources}} sources ({{active}} active) · {{outputs}} outputs' })} + {debateCountsLabel(t, counts)}

{oneOff &&

{t('debate.oneOffActive', 'One-off mode is on')}

}
diff --git a/src/components/features/debate/CurrentDebateStrip.tsx b/src/components/features/debate/CurrentDebateStrip.tsx index 128951514..fc1ac4516 100644 --- a/src/components/features/debate/CurrentDebateStrip.tsx +++ b/src/components/features/debate/CurrentDebateStrip.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { shouldPrefillMotion } from '@/lib/debate/motion'; @@ -30,6 +31,16 @@ export function useDebateCounts(debateId: string | null | undefined) { return { sources, activeSources, outputs: outputs + sparring }; } +/** "3 sources (2 active) · 1 output", with each count in its own plural form. */ +// eslint-disable-next-line react-refresh/only-export-components +export function debateCountsLabel(t: TFunction, counts: { sources: number; activeSources: number; outputs: number }): string { + return t('debate.counts', { + sources: t('debate.sourceCount', { count: counts.sources }), + active: t('debate.activeCount', { count: counts.activeSources }), + outputs: t('debate.outputCount', { count: counts.outputs }), + }); +} + /** * Fills a feature's motion from the Current Debate on open and after a switch, * without overwriting text the user typed themselves. diff --git a/src/components/features/factoids/FactoidsResults.tsx b/src/components/features/factoids/FactoidsResults.tsx index 65e630953..61190911c 100644 --- a/src/components/features/factoids/FactoidsResults.tsx +++ b/src/components/features/factoids/FactoidsResults.tsx @@ -230,7 +230,7 @@ export function FactoidsResults({ result, discoveredSources, onDiscuss, onSave, {/* Verification Status */}
@@ -264,7 +264,7 @@ export function FactoidsResults({ result, discoveredSources, onDiscuss, onSave, {/* Factoids */}
diff --git a/src/components/features/generate/GenerateResults.tsx b/src/components/features/generate/GenerateResults.tsx index 7a03fa408..42ae220c3 100644 --- a/src/components/features/generate/GenerateResults.tsx +++ b/src/components/features/generate/GenerateResults.tsx @@ -8,7 +8,7 @@ import { Separator } from '@/components/ui/separator'; import { Markdown } from '@/components/ui/markdown'; import type { GenerateOutput, Contention } from '@/lib/ai/schemas/generate'; import { toast } from 'sonner'; -import { formatEuropeanDateTime } from '@/lib/date-format'; +import { formatDecimal, formatEuropeanDateTime } from '@/lib/date-format'; import { ExportToolbar } from '@/components/features/ExportToolbar'; import { ResultHeader } from '@/components/features/tools/ResultHeader'; import { CopyResultButton, SaveResultAction } from '@/components/features/tools/result-actions'; @@ -83,7 +83,7 @@ function ArgumentCard({ contention, index, unverified }: { contention: Contentio function formatDuration(ms: number | undefined, t: (key: string, options?: Record) => string) { if (!ms || ms < 0) return null; if (ms < 1000) return t('generateResults.durationMs', { value: ms }); - const value = (ms / 1000).toFixed(ms < 10000 ? 1 : 0); + const value = formatDecimal(ms / 1000, ms < 10000 ? 1 : 0); return t('generateResults.durationS', { value }); } diff --git a/src/lib/date-format.ts b/src/lib/date-format.ts index 7148d5b7a..480ef951c 100644 --- a/src/lib/date-format.ts +++ b/src/lib/date-format.ts @@ -10,17 +10,23 @@ interface LocaleFormatters { number: Intl.NumberFormat; } +// Spanish and Hungarian leave four-digit numbers ungrouped by default; group +// them too so a limit reads "1.200" or "1 200", never "1200". The ES2021 lib +// types `useGrouping` as a boolean only. +const ALWAYS_GROUP = { useGrouping: 'always' } as unknown as Intl.NumberFormatOptions; + function createFormatters(language: AppLanguage): LocaleFormatters { const locale = toIntlLocale(language); return { + // A month name keeps the date unambiguous: "23 Oct 2026", not "23/10/2026". date: new Intl.DateTimeFormat(locale, { - day: '2-digit', - month: '2-digit', + day: 'numeric', + month: 'short', year: 'numeric', }), dateTime: new Intl.DateTimeFormat(locale, { - day: '2-digit', - month: '2-digit', + day: 'numeric', + month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', @@ -31,7 +37,7 @@ function createFormatters(language: AppLanguage): LocaleFormatters { minute: '2-digit', hour12: false, }), - number: new Intl.NumberFormat(locale), + number: new Intl.NumberFormat(locale, ALWAYS_GROUP), }; } @@ -47,6 +53,10 @@ function activeLanguage(): AppLanguage { return isAppLanguage(i18n.resolvedLanguage) ? i18n.resolvedLanguage : 'en'; } +function notAvailable(language: AppLanguage): string { + return i18n.t('common.notAvailable', { lng: language }); +} + function toValidDate(value: Date | number | string): Date | null { if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; // Date-only strings (e.g. "2026-05-20") are parsed as UTC midnight by spec. @@ -60,20 +70,20 @@ function toValidDate(value: Date | number | string): Date | null { export function formatEuropeanDate( value: Date | number | string, - fallback = 'N/A', + fallback?: string, language: AppLanguage = activeLanguage(), ): string { const date = toValidDate(value); - return date ? FORMATTERS[language].date.format(date) : fallback; + return date ? FORMATTERS[language].date.format(date) : fallback ?? notAvailable(language); } export function formatEuropeanDateTime( value: Date | number | string, - fallback = 'N/A', + fallback?: string, language: AppLanguage = activeLanguage(), ): string { const date = toValidDate(value); - return date ? FORMATTERS[language].dateTime.format(date) : fallback; + return date ? FORMATTERS[language].dateTime.format(date) : fallback ?? notAvailable(language); } export function formatEuropeanTime( @@ -89,11 +99,24 @@ export function formatNumber(value: number, language: AppLanguage = activeLangua return FORMATTERS[language].number.format(value); } +const DECIMAL_FORMATTERS = new Map(); + +/** A number with exactly `digits` decimals in the language's notation: "1.5" in English, "1,5" in Spanish and Hungarian. */ +export function formatDecimal(value: number, digits: number, language: AppLanguage = activeLanguage()): string { + const key = `${language}:${digits}`; + let formatter = DECIMAL_FORMATTERS.get(key); + if (!formatter) { + formatter = new Intl.NumberFormat(toIntlLocale(language), { minimumFractionDigits: digits, maximumFractionDigits: digits }); + DECIMAL_FORMATTERS.set(key, formatter); + } + return formatter.format(value); +} + export function formatRelativeDate( value: Date | number | string, language: AppLanguage = activeLanguage(), - fallback = 'N/A', + fallback?: string, ): string { const date = toValidDate(value); - return date ? formatDistanceToNow(date, { addSuffix: true, locale: DATE_FNS_LOCALES[language] }) : fallback; + return date ? formatDistanceToNow(date, { addSuffix: true, locale: DATE_FNS_LOCALES[language] }) : fallback ?? notAvailable(language); } diff --git a/src/lib/pdf-export-utils.ts b/src/lib/pdf-export-utils.ts index 795107709..f1c0c10e8 100644 --- a/src/lib/pdf-export-utils.ts +++ b/src/lib/pdf-export-utils.ts @@ -227,7 +227,7 @@ async function createPDFDocument(): Promise { doc.setFont(PDF_FONT, 'normal'); doc.setTextColor(...COLORS.secondary); doc.text( - `${i18n.t('export.generatedBy')} • ${formatEuropeanDate(new Date(), 'N/A', language)} • ${i18n.t('export.page')} ${i} ${i18n.t('export.of')} ${pageCount}`, + `${i18n.t('export.generatedBy')} • ${formatEuropeanDate(new Date(), undefined, language)} • ${i18n.t('export.page')} ${i} ${i18n.t('export.of')} ${pageCount}`, pageWidth / 2, pageHeight - 10, { align: 'center' } @@ -629,9 +629,9 @@ export async function exportFactoidsToPdf(result: FactoidsOutput): Promise { const pdf2 = pdf; // Access outer scope pdf2.addParagraph(`${i + 1}. ${f.shortClaim}`); - pdf2.addParagraph(` ${i18n.t('export.context')}: ${f.context || 'N/A'}`, 5); - pdf2.addParagraph(` ${i18n.t('export.limitations')}: ${f.limitations || 'N/A'}`, 5); - pdf2.addParagraph(` ${i18n.t('export.howToUse')}: ${f.howToUse || 'N/A'}`, 5); + pdf2.addParagraph(` ${i18n.t('export.context')}: ${f.context || i18n.t('common.notAvailable')}`, 5); + pdf2.addParagraph(` ${i18n.t('export.limitations')}: ${f.limitations || i18n.t('common.notAvailable')}`, 5); + pdf2.addParagraph(` ${i18n.t('export.howToUse')}: ${f.howToUse || i18n.t('common.notAvailable')}`, 5); if (f.citation?.url) { pdf2.addParagraph(` ${i18n.t('export.source')}: ${f.citation.url}`, 5); } diff --git a/src/locales/en.json b/src/locales/en.json index ef4c60129..b5e7ca6ad 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -247,7 +247,8 @@ "deleted": "Deleted", "discuss": "Discuss", "retry": "Retry", - "undo": "Undo" + "undo": "Undo", + "notAvailable": "N/A" }, "aiClient": { "inputShortened": "Input was shortened", @@ -1229,7 +1230,13 @@ "exportError": "An unexpected error occurred during export", "weaknessesCount_one": "{{count}} issue found, sorted by severity", "weaknessesCount_other": "{{count}} issues found, sorted by severity", - "resultTitle": "Critique" + "resultTitle": "Critique", + "criticalCount_one": "{{count}} Critical", + "criticalCount_other": "{{count}} Critical", + "majorCount_one": "{{count}} Major", + "majorCount_other": "{{count}} Major", + "minorCount_one": "{{count}} Minor", + "minorCount_other": "{{count}} Minor" }, "brainstormForm": { "motion": "Motion / Topic", @@ -1297,7 +1304,6 @@ "unverified": "Unverified", "pending": "Pending", "importantNotes": "Important Notes", - "found": "Found", "factoids": "Factoids", "factoidsDesc": "Evidence points with citations", "findMore": "Find More Evidence", @@ -1322,7 +1328,11 @@ "exportError": "An unexpected error occurred during export", "noVerified": "No verified matching sources were found. Try a broader source type or timeframe.", "useSuggestion": "Search this", - "resultTitle": "Evidence" + "resultTitle": "Evidence", + "foundCount_one": "Found {{count}} factoid", + "foundCount_other": "Found {{count}} factoids", + "verifiedCount_one": "{{count}} Verified", + "verifiedCount_other": "{{count}} Verified" }, "askChat": { "title": "Ask Your Coach", @@ -1480,7 +1490,7 @@ "displayLabel": "Timer display", "speechProgress": "{{speech}} progress", "selectFormat": "Select Format", - "formatStandard": "Standard (4-6-6-3)", + "formatStandard": "School 4-6-6-3", "formatLD": "Lincoln-Douglas", "formatPF": "Public Forum", "formatPolicy": "Policy", @@ -1669,7 +1679,6 @@ "deleteCaseLabel": "Delete {{title}}", "load": "Load", "resume": "Resume", - "messages": "messages", "caseDeleted": "Saved case deleted.", "deleteCaseDesc": "This removes the saved case from your history.", "deleteCaseTitle": "Delete saved case?", @@ -1707,7 +1716,6 @@ "unsupportedFile": "Unsupported file", "importPreview": "Import preview", "saveImported": "Save imported source", - "words": "words", "folder": "Folder", "generalFolder": "General", "textSource": "Text", @@ -1748,7 +1756,6 @@ "titlePlaceholder": "Source title...", "originPlaceholder": "URL or reference...", "contentPlaceholder": "Paste source content...", - "sourcesCount": "sources", "emptyTitle": "No Sources Yet", "emptyDesc": "Add sources to ground AI responses in real evidence.", "manualEntry": "Manual Entry", @@ -1787,7 +1794,7 @@ "restoreFailed": "Could not restore the source", "deleteFailed": "Could not delete the source.", "readingFile": "Reading file…", - "summary": "{{total}} sources · {{active}} active · {{folders}} folders", + "summary": "{{sources}} · {{active}} · {{folders}}", "mergeFolderTitle": "Merge into {{name}}?", "mergeFolders": "Merge folders", "mergeFailed": "Could not merge the folders. Try again.", @@ -1800,7 +1807,15 @@ "sourceSaved": "Saved {{title}} to the Library", "duplicateUrlDesc": "This page is already in this debate's Library.", "loadingSources": "Loading sources…", - "folderUpdateFailed": "Could not change this folder" + "folderUpdateFailed": "Could not change this folder", + "sourceCount_one": "{{count}} source", + "sourceCount_other": "{{count}} sources", + "activeCount_one": "{{count}} active", + "activeCount_other": "{{count}} active", + "folderCount_one": "{{count}} folder", + "folderCount_other": "{{count}} folders", + "wordCount_one": "{{formatted}} word", + "wordCount_other": "{{formatted}} words" }, "autoSavedSources": { "undo": "Undo auto-saved sources", @@ -1824,7 +1839,8 @@ "goToApp": "Go to App", "openApp": "Open App", "sharedOn": "Shared on", - "views": "views" + "viewCount_one": "{{count}} view", + "viewCount_other": "{{count}} views" }, "privacyPage": { "title": "Privacy Policy", @@ -2358,7 +2374,7 @@ "deleted": "Debate deleted", "deleteFailed": "Could not delete the debate", "lastActivity": "Last activity {{when}}", - "counts": "{{sources}} sources ({{active}} active) · {{outputs}} outputs", + "counts": "{{sources}} ({{active}}) · {{outputs}}", "menuAria": "Current debate: {{motion}}", "open": "Open debate", "useOneOff": "Use one-off mode", @@ -2435,7 +2451,13 @@ "switching": "Switching…", "deleting": "Deleting…", "creating": "Creating…", - "loadingCurrent": "Loading your current debate…" + "loadingCurrent": "Loading your current debate…", + "activeCount_one": "{{count}} active", + "activeCount_other": "{{count}} active", + "outputCount_one": "{{count}} output", + "outputCount_other": "{{count}} outputs", + "sourceCount_one": "{{count}} source", + "sourceCount_other": "{{count}} sources" }, "debateContext": { "using": "Using {{list}}", diff --git a/src/locales/es.json b/src/locales/es.json index 728ded605..298133ba4 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -247,7 +247,8 @@ "deleted": "Eliminado", "discuss": "Discutir", "retry": "Reintentar", - "undo": "Deshacer" + "undo": "Deshacer", + "notAvailable": "N/D" }, "aiClient": { "inputShortened": "La entrada se acortó", @@ -476,7 +477,7 @@ "signOutFailed": "No se pudo cerrar la sesión. Inténtalo de nuevo.", "aiModels": "Modelos de IA", "aiModelsDesc": "Selecciona tu modelo preferido desde cada página de función usando el selector del encabezado.", - "admin": "Admin", + "admin": "Administración", "adminDesc": "Abre el panel de administración para uso, usuarios, límites y códigos promocionales.", "openAdmin": "Abrir panel de admin", "developer": "Desarrollador", @@ -603,7 +604,7 @@ "createDemoCode": "Crear código demo", "createDemoCodeDescription": "Emite un código de un solo uso para una cuenta demo. El código solo aparece después de crearlo.", "label": "Etiqueta", - "labelPlaceholder": "p. ej., Demo escolar de septiembre", + "labelPlaceholder": "p. ej. Demo escolar de septiembre", "emailOptional": "Email asignado (opcional)", "expiryOptional": "Caducidad (opcional)", "maxUsesOptional": "Usos máximos (opcional)", @@ -743,7 +744,7 @@ "promos": { "createTitle": "Crear código promocional", "code": "Código", - "codePlaceholder": "p. ej., CIT2026", + "codePlaceholder": "p. ej. CIT2026", "type": "Tipo", "types": { "extended_trial": "Prueba ampliada", @@ -794,7 +795,7 @@ "currentEnds": "La prueba actual termina el {{date}}", "days": "Días", "notes": "Notas (opcional)", - "notesPlaceholder": "p. ej., socio de torneo o solicitud de soporte", + "notesPlaceholder": "p. ej. socio de torneo o solicitud de soporte", "cancel": "Cancelar", "action_one": "Regalar {{count}} día", "action_other": "Regalar {{count}} días", @@ -925,7 +926,7 @@ "sampleSparringTitle": "Plan de la ronda", "sampleOpeningLabel": "Apertura", "sampleOpening": "Expón la regla, la excepción y la medida del éxito.", - "sampleResponseLabel": "Respuesta", + "sampleResponseLabel": "Réplica", "sampleResponse": "Compara el tiempo de aprendizaje con el coste de hacer cumplir la regla." } }, @@ -944,7 +945,7 @@ "crossfire": "Interrogatorio", "crossfireDesc": "Practica preguntas y respuestas precisas en un intercambio cronometrado.", "sparring": "Sparring", - "sparringDesc": "Practica una ronda cronometrada contra una oponente y revisa la tarjeta de puntuación.", + "sparringDesc": "Practica una ronda cronometrada contra un oponente y revisa la tarjeta de puntuación.", "library": "Biblioteca de fuentes", "libraryDesc": "Gestiona fuentes que fundamentan las respuestas de IA con evidencia real.", "timer": "Cronómetro de debate", @@ -1085,7 +1086,7 @@ }, "generateForm": { "motion": "Moción / tema", - "motionPlaceholder": "ej., Esta cámara cree que la inteligencia artificial creará más empleos de los que destruirá", + "motionPlaceholder": "p. ej. Esta cámara cree que la inteligencia artificial creará más empleos de los que destruirá", "side": "Lado", "sidePro": "A favor", "sideCon": "En contra", @@ -1229,11 +1230,17 @@ "exportError": "Ocurrió un error inesperado durante la exportación", "weaknessesCount_one": "{{count}} problema encontrado, ordenado por gravedad", "weaknessesCount_other": "{{count}} problemas encontrados, ordenados por gravedad", - "resultTitle": "Crítica" + "resultTitle": "Crítica", + "criticalCount_one": "{{count}} crítico", + "criticalCount_other": "{{count}} críticos", + "majorCount_one": "{{count}} mayor", + "majorCount_other": "{{count}} mayores", + "minorCount_one": "{{count}} menor", + "minorCount_other": "{{count}} menores" }, "brainstormForm": { "motion": "Moción / tema", - "motionPlaceholder": "ej., La Renta Básica Universal debería implementarse globalmente", + "motionPlaceholder": "p. ej. La Renta Básica Universal debería implementarse globalmente", "side": "Lado a explorar", "sideBoth": "Explorar ambos lados", "sidePro": "Solo a favor", @@ -1274,7 +1281,7 @@ }, "factoidsForm": { "claim": "Declaración", - "claimPlaceholder": "ej., La energía renovable es más rentable que los combustibles fósiles", + "claimPlaceholder": "p. ej. La energía renovable es más rentable que los combustibles fósiles", "direction": "Postura", "directionFor": "A favor", "directionAgainst": "En contra", @@ -1282,7 +1289,7 @@ "regionPlaceholder": "Global por defecto", "sourceCount": "Fuentes", "timeframe": "Período (opcional)", - "timeframePlaceholder": "ej., 2020-2024, Última década", + "timeframePlaceholder": "p. ej. 2020-2024, Última década", "sourceType": "Tipo de fuente", "sourceAny": "Cualquier fuente confiable", "sourceAcademic": "Solo académicas", @@ -1297,7 +1304,6 @@ "unverified": "No verificado", "pending": "Pendiente", "importantNotes": "Notas importantes", - "found": "Encontrados", "factoids": "Datos", "factoidsDesc": "Puntos de evidencia con citas", "findMore": "Buscar más evidencia", @@ -1322,7 +1328,11 @@ "exportError": "Ocurrió un error inesperado durante la exportación", "noVerified": "No se encontraron fuentes verificadas que coincidan. Prueba con otro tipo de fuente o un período más amplio.", "useSuggestion": "Buscar esto", - "resultTitle": "Evidencia" + "resultTitle": "Evidencia", + "foundCount_one": "{{count}} dato encontrado", + "foundCount_other": "{{count}} datos encontrados", + "verifiedCount_one": "{{count}} verificado", + "verifiedCount_other": "{{count}} verificados" }, "askChat": { "title": "Pregunta a tu coach", @@ -1349,7 +1359,7 @@ "followUp": "Preguntas de seguimiento:", "pros": "Pros", "cons": "Contras", - "placeholder": "Pregunta a tu coach de debate...", + "placeholder": "Pregunta algo…", "inputLabel": "Mensaje para tu coach de debate", "placeholderListening": "Escuchando...", "saveToLibrary": "Guardar en biblioteca", @@ -1461,7 +1471,7 @@ "onboarding": { "welcome": "¡Bienvenido a DebateLab! 🎉", "ready": "¡Estás listo! 🚀", - "readyDesc": "Explora las otras funciones: Atacar argumentos, Chat con Coach e Investigar datos.", + "readyDesc": "Elige el modo que se ajuste a lo que necesitas para la próxima ronda.", "back": "Atrás", "close": "Cerrar", "getStarted": "¡Comenzar!", @@ -1476,14 +1486,14 @@ "debateCurrentDesc": "Todo en DebateLab pertenece a tu debate actual: su moción, sus fuentes y tus resultados. Ábrelo o cámbialo aquí." }, "timer": { - "title": "Temporizador de debate", - "displayLabel": "Pantalla del temporizador", + "title": "Cronómetro de debate", + "displayLabel": "Pantalla del cronómetro", "speechProgress": "Progreso de {{speech}}", "selectFormat": "Seleccionar formato", - "formatStandard": "Estándar (4-6-6-3)", + "formatStandard": "Formato escolar 4-6-6-3", "formatLD": "Lincoln-Douglas", - "formatPF": "Foro público", - "formatPolicy": "Política", + "formatPF": "Public Forum", + "formatPolicy": "Policy", "timeUp": "¡Se acabó el tiempo!", "speaking": "Hablando", "paused": "Pausado", @@ -1495,7 +1505,7 @@ "timeUpSignal": "Señal de tiempo agotado", "twoBeeps": "Dos pitidos", "spaceStartStop": "Espacio para iniciar/detener", - "reset": "Reiniciar temporizador", + "reset": "Reiniciar cronómetro", "resetKey": "R para reiniciar", "arrowsChange": "Flechas para cambiar orador", "speeches": { @@ -1535,7 +1545,7 @@ }, "disableSound": "Desactivar sonido", "enableSound": "Activar sonido", - "resetDone": "Temporizador reiniciado" + "resetDone": "Cronómetro reiniciado" }, "flowchart": { "title": "Diagrama de argumentos", @@ -1669,7 +1679,6 @@ "deleteCaseLabel": "Eliminar {{title}}", "load": "Cargar", "resume": "Reanudar", - "messages": "mensajes", "caseDeleted": "Se eliminó el caso guardado.", "deleteCaseDesc": "Esto eliminará el caso guardado de tu historial.", "deleteCaseTitle": "¿Eliminar el caso guardado?", @@ -1705,7 +1714,6 @@ "chooseFile": "Elegir archivo", "importPreview": "Vista previa de importación", "saveImported": "Guardar fuente importada", - "words": "palabras", "folder": "Carpeta", "generalFolder": "General", "textSource": "Texto", @@ -1743,7 +1751,6 @@ "titlePlaceholder": "Título de la fuente...", "originPlaceholder": "URL o referencia...", "contentPlaceholder": "Pega el contenido de la fuente...", - "sourcesCount": "fuentes", "emptyTitle": "Sin fuentes aún", "emptyDesc": "Añade fuentes para fundamentar las respuestas de IA con evidencia real.", "manualEntry": "Entrada manual", @@ -1787,7 +1794,7 @@ "restoreFailed": "No se pudo restaurar la fuente", "deleteFailed": "No se pudo eliminar la fuente.", "readingFile": "Leyendo el archivo…", - "summary": "{{total}} fuentes · {{active}} activas · {{folders}} carpetas", + "summary": "{{sources}} · {{active}} · {{folders}}", "mergeFolderTitle": "¿Fusionar con {{name}}?", "mergeFolders": "Fusionar carpetas", "mergeFailed": "No se pudieron fusionar las carpetas. Inténtalo de nuevo.", @@ -1800,7 +1807,15 @@ "sourceSaved": "{{title}} se guardó en la biblioteca", "duplicateUrlDesc": "Esta página ya está en la biblioteca de este debate.", "loadingSources": "Cargando fuentes…", - "folderUpdateFailed": "No se pudo cambiar esta carpeta" + "folderUpdateFailed": "No se pudo cambiar esta carpeta", + "sourceCount_one": "{{count}} fuente", + "sourceCount_other": "{{count}} fuentes", + "activeCount_one": "{{count}} activa", + "activeCount_other": "{{count}} activas", + "folderCount_one": "{{count}} carpeta", + "folderCount_other": "{{count}} carpetas", + "wordCount_one": "{{formatted}} palabra", + "wordCount_other": "{{formatted}} palabras" }, "autoSavedSources": { "undo": "Deshacer fuentes autoguardadas", @@ -1824,7 +1839,8 @@ "goToApp": "Ir a la app", "openApp": "Abrir app", "sharedOn": "Compartido el", - "views": "vistas" + "viewCount_one": "{{count}} vista", + "viewCount_other": "{{count}} vistas" }, "privacyPage": { "title": "Política de privacidad", @@ -1862,7 +1878,7 @@ "s2": "Descripción del servicio", "p2": "DebateLab es una herramienta de preparación de debates impulsada por IA que proporciona generación de argumentos, análisis y funciones de coaching.", "s3": "Requisitos de cuenta", - "p3": "Puedes iniciar sesión con Google, mediante un enlace enviado por email o con un código demo emitido por un administrador. Los códigos demo proporcionan acceso temporal a una cuenta designada. Eres responsable de toda la actividad de tu cuenta.", + "p3": "Puedes iniciar sesión con Google, con un código de seis dígitos enviado por email o con un código demo emitido por un administrador. Los códigos demo proporcionan acceso temporal a una cuenta designada. Eres responsable de toda la actividad de tu cuenta.", "s4": "Suscripción y facturación", "p4": "Las pruebas gratuitas duran 3 días con solicitudes limitadas. Las suscripciones Pro cuestan $5/mes más impuestos, facturadas vía Stripe. Cancela en cualquier momento.", "s5": "Uso aceptable", @@ -2004,7 +2020,7 @@ "format": { "standard": "Formato escolar 4-6-6-3", "ld": "Lincoln-Douglas", - "pf": "Foro público" + "pf": "Public Forum" }, "formatDescription": { "standard": "El formato del colegio: Introductor, R1, R2 y Conclusor para ambos lados.", @@ -2037,7 +2053,7 @@ "difficulty": { "easy": "Calentamiento", "medium": "Torneo", - "hard": "Implacable" + "hard": "Despiadado" }, "roleGuidance": { "introductor": "Abre con un exordio, define los términos clave, fija el marco y la tesis, y presenta la hoja de ruta. Reserva los argumentos completos para R1.", @@ -2358,7 +2374,7 @@ "deleted": "Debate eliminado", "deleteFailed": "No se pudo eliminar el debate", "lastActivity": "Última actividad {{when}}", - "counts": "{{sources}} fuentes ({{active}} activas) · {{outputs}} resultados", + "counts": "{{sources}} ({{active}}) · {{outputs}}", "menuAria": "Debate actual: {{motion}}", "open": "Abrir debate", "useOneOff": "Usar modo puntual", @@ -2416,13 +2432,13 @@ "factoids": "Datos", "chat": "Chat de preguntas", "flowchart": "Diagrama", - "crossfire": "Contrainterrogatorio", + "crossfire": "Interrogatorio", "sparring": "Sparring", "source": "Fuente" }, "formats": { "custom": "Personalizado", - "bp": "Parlamentario británico", + "bp": "British Parliamentary", "policy": "Policy", "classroom": "Aula" }, @@ -2435,7 +2451,13 @@ "switching": "Cambiando…", "deleting": "Eliminando…", "creating": "Creando…", - "loadingCurrent": "Cargando tu debate actual…" + "loadingCurrent": "Cargando tu debate actual…", + "activeCount_one": "{{count}} activa", + "activeCount_other": "{{count}} activas", + "outputCount_one": "{{count}} resultado", + "outputCount_other": "{{count}} resultados", + "sourceCount_one": "{{count}} fuente", + "sourceCount_other": "{{count}} fuentes" }, "debateContext": { "using": "Usando {{list}}", @@ -2557,7 +2579,7 @@ }, "shortAnswer": { "question": "¿Cuál es la mejor herramienta de IA para practicar debate?", - "answer": "Depende de lo que necesites. En {{asOf}}, DebateLab encaja con estudiantes y debatientes de competición que quieren un solo lugar para construir casos, encontrar puntos débiles, comprobar evidencia y hacer rondas de práctica cronometradas, en inglés, español o húngaro: prueba gratis de 3 días sin tarjeta y después $5/mes + impuestos. Si quieres practicar gratis contra un oponente de IA en estilo World Schools o Parlamentario Asiático, debatelab.ai, un producto distinto, dice ser gratuito. Si sobre todo necesitas ideas o ayuda para escribir sobre cualquier tema, un asistente general como ChatGPT es más flexible.", + "answer": "Depende de lo que necesites. En {{asOf}}, DebateLab encaja con estudiantes y debatientes de competición que quieren un solo lugar para construir casos, encontrar puntos débiles, comprobar evidencia y hacer rondas de práctica cronometradas, en inglés, español o húngaro: prueba gratis de 3 días sin tarjeta y después $5/mes + impuestos. Si quieres practicar gratis contra un oponente de IA en estilo World Schools o Asian Parliamentary, debatelab.ai, un producto distinto, dice ser gratuito. Si sobre todo necesitas ideas o ayuda para escribir sobre cualquier tema, un asistente general como ChatGPT es más flexible.", "compare": "Comparar herramientas de IA para debate" }, "sessionQuota": { diff --git a/src/locales/hu.json b/src/locales/hu.json index 33d5b20a9..df2234266 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -187,7 +187,7 @@ "copyPrivateSource": "Másolás a privát könyvtárból", "searchEvidence": "Bizonyíték keresése", "insertCitation": "Hivatkozás beszúrása", - "useInContext": "Használat az AI-kontextusban", + "useInContext": "Használat az MI-kontextusban", "teamThreads": "Csapatbeszélgetések", "threadName": "Beszélgetés neve", "createThread": "Létrehozás", @@ -199,13 +199,13 @@ "practiceFailed": "A gyakorlási elem nem menthető.", "sharedToolScope": "Közös munkatér-menet · {{document}}", "returnToWorkspace": "Vissza a munkatérhez", - "suggestRewrite": "AI-átírás javaslata", + "suggestRewrite": "MI-átírás javaslata", "sharePrivateResult": "Privát eredmény megosztása", "shareResult": "Megosztás", "resumePractice": "Folytatás", "contextSummary": "Bevonva: kijelölt dokumentum, {{included}} forrás{{thread}}. Kihagyva: {{omitted}} forrás.", "contextThread": ", közös beszélgetés", - "contextDocuments": "AI-kontextus dokumentumai", + "contextDocuments": "MI-kontextus dokumentumai", "proposal": { "insert": "Javasolt beszúrás", "delete": "Javasolt törlés", @@ -247,7 +247,8 @@ "deleted": "Törölve", "discuss": "Megvitatás", "retry": "Újrapróbálás", - "undo": "Visszavonás" + "undo": "Visszavonás", + "notAvailable": "nincs adat" }, "aiClient": { "inputShortened": "A bemenet rövidítve lett", @@ -393,7 +394,7 @@ "flowchartOtherDebate": "A folyamatábra csak a jelenlegi vitában létrehozott esetet fogadja. Hozd létre itt az esetet, hogy elküldhesd." }, "attack": { - "emptyTitle": "Kegyetlen mód", + "emptyTitle": "Kíméletlen mód", "emptyDesc": "Illeszd be az érvedet kíméletlen kritikáért, logikai hibák azonosításáért és megvalósítható javításokért.", "failed": "Támadás elemzés sikertelen", "working": "Az eseted átnézése…", @@ -440,9 +441,9 @@ "chat": {}, "settings": { "title": "Beállítások", - "aiOutputLanguage": "Nyelv (felület + MI kimenet)", + "aiOutputLanguage": "Nyelv (felület + MI-kimenet)", "uiLanguage": "Felület nyelve", - "outputLanguage": "MI kimeneti nyelv", + "outputLanguage": "MI-kimenet nyelve", "theme": "Téma", "light": "Világos", "dark": "Sötét", @@ -474,9 +475,9 @@ "signInDemo": "Bejelentkezés: demókód", "signOut": "Kijelentkezés", "signOutFailed": "Nem sikerült kijelentkezni. Próbáld újra.", - "aiModels": "MI modellek", + "aiModels": "MI-modellek", "aiModelsDesc": "A kívánt modellt az egyes funkcióoldalak fejlécében lévő modellválasztóval állíthatod be.", - "admin": "Admin", + "admin": "Adminisztráció", "adminDesc": "Admin irányítópult megnyitása használathoz, felhasználókhoz, limitekhez és promókódokhoz.", "openAdmin": "Admin panel megnyitása", "developer": "Fejlesztői", @@ -534,7 +535,7 @@ "subscriptionEnded": "A Pro-előfizetésed véget ért. Fizess elő újra az MI-eszközök további használatához." }, "login": { - "title": "Bejelentkezés a DebateLab-ba", + "title": "Bejelentkezés a DebateLabba", "subtitle": "Jelentkezz be Google-lal, e-maillel vagy adminisztrátor által kiadott demókóddal.", "google": "Folytatás Google-lal", "emailTab": "E-mail", @@ -580,7 +581,7 @@ "redirecting": "Átirányítás...", "failed": "Nem sikerült elindítani a bejelentkezést", "tryAgain": "Próbáld újra.", - "backHome": "Vissza a DebateLabhez", + "backHome": "Vissza a DebateLabhoz", "sessionExpired": "A munkameneted lejárt. Jelentkezz be újra.", "errors": { "demoNotFound": "Nincs ilyen demókód.", @@ -732,7 +733,7 @@ "confirmMakeAdmin": "Adminná teszed: {{email}}?", "confirmMakeAdminDesc": "Az adminok megnyithatják az adminoldalt, és módosíthatják mások szerepkörét és csomagját.", "confirmBan": "Letiltod: {{email}}?", - "confirmBanDesc": "Addig nem használhatja a DebateLabet, amíg fel nem oldod a tiltást.", + "confirmBanDesc": "Addig nem használhatja a DebateLabot, amíg fel nem oldod a tiltást.", "confirmUnban": "Feloldod a tiltást: {{email}}?", "confirmRemoveAdmin": "Elveszed az admin jogot: {{email}}?", "confirmRoleDesc": "Átlagos felhasználó lesz.", @@ -925,15 +926,15 @@ "sampleSparringTitle": "Fordulóterv", "sampleOpeningLabel": "Nyitás", "sampleOpening": "Mondd el a szabályt, a kivételt és a siker mércéjét.", - "sampleResponseLabel": "Válasz", + "sampleResponseLabel": "Reagálás", "sampleResponse": "Hasonlítsd össze a tanulási időt a szabály betartatásának költségével." } }, "features": { "title": "Minden, ami a győzelemhez kell", - "generate": "Eset generátor", + "generate": "Esetgenerátor", "generateDesc": "Építs teljes eseteket strukturált érvekkel, bizonyítékokkal és ellen-stratégiákkal.", - "attack": "Eset támadó", + "attack": "Esettámadó", "attackDesc": "Teszteld az érveidet és találd meg a gyengeségeket az ellenfelek előtt.", "brainstorm": "Ötletelés", "brainstormDesc": "Fedezz fel szokatlan szögeket és találj friss érvelési utakat.", @@ -947,7 +948,7 @@ "sparringDesc": "Gyakorolj egy időzített fordulót ellenféllel, majd nézd át az értékelőlapot.", "library": "Forráskönyvtár", "libraryDesc": "Kezeld a forrásokat, amelyek valós bizonyítékokkal alapozzák meg az MI válaszait.", - "timer": "Vita időmérő", + "timer": "Vitaidőmérő", "timerDesc": "Gyakorolj versenypontos időzítéssel.", "flowchart": "Folyamatábra", "flowchartDesc": "Térképezd az érvstruktúrákat és cáfolati kapcsolatokat vizuálisan.", @@ -996,11 +997,11 @@ "proPrice": "$5", "proPeriod": "/hó + adó", "recommended": "Ajánlott", - "trialFeature1": "Kezdő MI felkészülési hozzáférés", + "trialFeature1": "Kezdő MI-felkészülési hozzáférés", "trialFeature2": "Mind a {{modes}} felkészülési mód és a {{utilities}} segédeszköz", "trialFeature3": "Kezdő modellek", "trialFeature4": "3 napos hozzáférés", - "proFeature1": "Bővített MI felkészülési hozzáférés", + "proFeature1": "Bővített MI-felkészülési hozzáférés", "proFeature2": "Mind a {{modes}} felkészülési mód és a {{utilities}} segédeszköz", "proFeature3": "Mind a 10 modell, köztük a Claude Opus és Sonnet, a GPT-6.1 Sol és a Gemini", "proFeature4": "Elsőbbségi támogatás", @@ -1019,7 +1020,7 @@ "q1": "Mennyibe kerül a DebateLab?", "a1": "{{asOf}} állapot szerint a DebateLab 3 napos ingyenes próbát kínál bankkártya nélkül. Ezután a Pro $5/hó + adó, bővített hozzáféréssel és mind a 10 modellel. Nincs állandó ingyenes csomag.", "q2": "Mit kapok a Pro-val?", - "a2": "Bővített MI felkészülési hozzáférés mind a 10 modellel, köztük a Claude Opus és Sonnet, a GPT-6.1 Sol és a Gemini modellekkel, Active Grounding™ kutatással és elsőbbségi támogatással.", + "a2": "Bővített MI-felkészülési hozzáférés mind a 10 modellel, köztük a Claude Opus és Sonnet, a GPT-6.1 Sol és a Gemini modellekkel, Active Grounding™ kutatással és elsőbbségi támogatással.", "q3": "Lemondhatom az előfizetésem?", "a3": "Igen, bármikor lemondhatod a Beállítások oldalon. A számlázási időszak végéig megtartod a hozzáférést.", "q4": "Az MI kitalál dolgokat?", @@ -1050,7 +1051,7 @@ "sub5Title": "Keresztkérdezési gyakorlás", "sub5Desc": "Gyakorold a célzott kérdéseket és válaszokat időzített párbeszédben.", "sub5Badge": "Új eszköz", - "sub6Title": "Vitapárbaj fordulók", + "sub6Title": "Vitapárbaj-fordulók", "sub6Desc": "Gyakorolj egy időzített vitafordulót, majd nézd át az értékelőlapot.", "sub6Badge": "Új eszköz", "sub1Badge": "Számlázás", @@ -1060,13 +1061,13 @@ }, "deepDive": { "title": "Active Grounding™ technológia", - "subtitle": "Hogyan tartjuk őszintén az MI válaszokat.", - "step1": "MI szkennelés Exa-val", + "subtitle": "Hogyan tartjuk őszintén az MI-válaszokat.", + "step1": "MI-szkennelés Exával", "step1Desc": "A neurális keresés releváns, magas minőségű forrásokat talál.", "step2": "Tudásbázis", "step2Desc": "A forrásaid vektorizálva vannak szemantikus egyeztetéshez.", "step3": "Kontextus injektálás", - "step3Desc": "Az ellenőrzött tények bekerülnek az MI promptba.", + "step3Desc": "Az ellenőrzött tények bekerülnek az MI-promptba.", "step4": "Megalapozott kimenet", "step4Desc": "A válaszok valós forrásokat idéznek, nem hallucinációkat.", "preview": { @@ -1098,7 +1099,7 @@ "moreOptions": "További opciók", "length": "Eset hossza", "lengthBrief": "Rövid", - "lengthStandard": "Standard", + "lengthStandard": "Normál", "lengthComprehensive": "Átfogó", "audience": "Szint", "audienceBeginner": "Kezdő", @@ -1115,7 +1116,7 @@ "startVoice": "Hangbevitel indítása", "stopVoice": "Hangbevitel leállítása", "dictate": "Diktáld az indítványt", - "styleNote": "A írásstílust befolyásolja; a kutatást nem változtatja meg." + "styleNote": "Az írásstílust befolyásolja; a kutatást nem változtatja meg." }, "generateResults": { "incomplete": "Generálás befejezetlen", @@ -1133,7 +1134,7 @@ "exordioDesc": "A meggyőző nyitásod", "hook": "Felvezetés", "transition": "Átvezetés", - "caseStructure": "Eset struktúra", + "caseStructure": "Esetstruktúra", "caseStructureDesc": "Az érvelési kereted", "thesis": "Tézis", "framework": "Keretrendszer", @@ -1142,7 +1143,7 @@ "noDefinitions": "Nincsenek megadott definíciók", "arguments": "Érvek", "structuredArguments": "strukturált érv", - "predictedOpponent": "Várható ellenfél eset", + "predictedOpponent": "Az ellenfél várható esete", "predictedOpponentDesc": "Amit valószínűleg érvelni fognak", "likelyArguments": "Valószínű érvek", "strategicWeaknesses": "Stratégiai gyengeségek", @@ -1229,7 +1230,13 @@ "exportError": "Váratlan hiba történt az exportálás során", "weaknessesCount_one": "{{count}} probléma található, súlyosság szerint rendezve", "weaknessesCount_other": "{{count}} probléma található, súlyosság szerint rendezve", - "resultTitle": "Kritika" + "resultTitle": "Kritika", + "criticalCount_one": "{{count}} kritikus", + "criticalCount_other": "{{count}} kritikus", + "majorCount_one": "{{count}} jelentős", + "majorCount_other": "{{count}} jelentős", + "minorCount_one": "{{count}} kisebb", + "minorCount_other": "{{count}} kisebb" }, "brainstormForm": { "motion": "Indítvány / téma", @@ -1297,7 +1304,6 @@ "unverified": "Nem ellenőrzött", "pending": "Függőben", "importantNotes": "Fontos megjegyzések", - "found": "Találat", "factoids": "Tény", "factoidsDesc": "Bizonyítékok hivatkozásokkal", "findMore": "További bizonyítékok keresése", @@ -1322,10 +1328,14 @@ "exportError": "Váratlan hiba történt az exportálás során", "noVerified": "Nem találtunk megfelelő, ellenőrzött forrásokat. Próbálj más forrástípust vagy hosszabb időszakot.", "useSuggestion": "Keresés erre", - "resultTitle": "Bizonyítékok" + "resultTitle": "Bizonyítékok", + "foundCount_one": "{{count}} tényt találtunk", + "foundCount_other": "{{count}} tényt találtunk", + "verifiedCount_one": "{{count}} ellenőrzött", + "verifiedCount_other": "{{count}} ellenőrzött" }, "askChat": { - "title": "Kérdezd az edződ", + "title": "Kérdezd az edződet", "subtitle": "Kapj válaszokat vitakérdésekre", "startConversation": "Indíts beszélgetést", "startDesc": "Kérdezz bármit vitastratégiáról, érvekről, vagy kérj segítséget az esetedhez.", @@ -1349,7 +1359,7 @@ "followUp": "Követő kérdések:", "pros": "Mellette", "cons": "Ellene", - "placeholder": "Kérdezd a vitaedződ...", + "placeholder": "Mit kérdeznél?", "inputLabel": "Üzenet a vitaedződnek", "placeholderListening": "Hallgatás...", "saveToLibrary": "Mentés a könyvtárba", @@ -1390,10 +1400,10 @@ "limitReachedDesc": "Elérted a mai keretet: {{current}} / {{limit}} MI-használatot vettél igénybe. A hozzáférés éjfélkor (UTC) frissül.", "weightedDesc": "Ez a modell {{units}} használatnak számít, és ma még {{remaining}} maradt. Válassz könnyebb modellt, vagy várj éjfélig (UTC).", "weightedTooLargeDesc": "Ez a modell {{units}} használatnak számít, ami több, mint a napi {{limit}} kereted. Válassz könnyebb modellt.", - "weeklyReached": "Elfogyott a heti MI keret", - "weeklyReachedDesc": "Felhasználtad az e heti MI keretet. Hétfőn 00:00-kor (UTC) frissül. Ha többre van szükséged, írj az ügyfélszolgálatnak.", + "weeklyReached": "Elfogyott a heti MI-keret", + "weeklyReachedDesc": "Felhasználtad az e heti MI-keretet. Hétfőn 00:00-kor (UTC) frissül. Ha többre van szükséged, írj az ügyfélszolgálatnak.", "ok": "Rendben", - "upgradeNote": "Több MI felkészülési hozzáférés + magas szintű érvelés", + "upgradeNote": "Több MI-felkészülési hozzáférés + magas szintű érvelés", "notNow": "Most nem", "gotIt": "Rendben", "paymentFailed": "Sikertelen fizetés", @@ -1428,13 +1438,13 @@ "current": "Jelenlegi", "recommended": "Ajánlott", "proPeriod": "/hó + adó", - "freeFeature1": "Kezdő MI felkészülési hozzáférés", + "freeFeature1": "Kezdő MI-felkészülési hozzáférés", "freeFeature2": "Kezdő modellek", "freeFeature3": "Minden vitafunkció", "freeFeature4": "Bankkártya nélkül", - "proFeature1": "Bővített MI felkészülési hozzáférés", + "proFeature1": "Bővített MI-felkészülési hozzáférés", "proFeature2": "Az összes modell, köztük a Claude Opus és Sonnet, a GPT-6.1 Sol és a Gemini", - "proFeature3": "Mélyreható kutatás Exa-val", + "proFeature3": "Mélyreható kutatás Exával", "proFeature4": "Elsőbbségi feldolgozás", "proFeature5": "Minden vitafunkció", "redirecting": "Átirányítás...", @@ -1459,9 +1469,9 @@ "freePeriod_other": "{{count}} napig" }, "onboarding": { - "welcome": "Üdvözlünk a DebateLab-ban! 🎉", + "welcome": "Üdvözlünk a DebateLabban! 🎉", "ready": "Készen állsz! 🚀", - "readyDesc": "Fedezd fel a többi funkciót: Érvek támadása, Edző chat és Tények kutatása.", + "readyDesc": "Válaszd azt a módot, amelyre a következő fordulóhoz szükséged van.", "back": "Vissza", "close": "Bezárás", "getStarted": "Kezdjük!", @@ -1476,14 +1486,14 @@ "debateCurrentDesc": "A DebateLabban minden az aktuális vitádhoz tartozik: a tételéhez, a forrásaihoz és az eredményeidhez. Itt nyithatod meg vagy cserélheted le." }, "timer": { - "title": "Vita időmérő", - "displayLabel": "Időmérő kijelző", + "title": "Vitaidőmérő", + "displayLabel": "Időmérő-kijelző", "speechProgress": "{{speech}} előrehaladása", "selectFormat": "Formátum választása", - "formatStandard": "Standard (4-6-6-3)", + "formatStandard": "Iskolai 4-6-6-3", "formatLD": "Lincoln-Douglas", - "formatPF": "Nyilvános fórum", - "formatPolicy": "Politikai", + "formatPF": "Public Forum", + "formatPolicy": "Policy", "timeUp": "Lejárt az idő!", "speaking": "Beszél", "paused": "Szüneteltetve", @@ -1503,7 +1513,7 @@ "intro": "Bevezető", "r1": "Cáfolat 1", "r2": "Cáfolat 2", - "conc": "Összegző" + "conc": "Záró" }, "ld": { "ac": "Igenlő felépítés", @@ -1539,7 +1549,7 @@ }, "flowchart": { "title": "Érvelési folyamatábra", - "load": "Betöltés", + "load": "Megnyitás", "save": "Mentés", "addNode": "Csomópont hozzáadása", "edit": "Szerkesztés", @@ -1548,7 +1558,7 @@ "saveChanges": "Változások mentése", "saveFlowchart": "Folyamatábra mentése", "savedFlowcharts": "Mentett folyamatábrák", - "savedFlowchartsDesc": "Tölts be egy korábban mentett folyamatábrát", + "savedFlowchartsDesc": "Nyiss meg egy korábban mentett folyamatábrát", "saveFlowchartDialog": "Folyamatábra mentése", "saveFlowchartDialogDesc": "Adj nevet a folyamatábrádnak a mentéshez", "editNode": "Csomópont szerkesztése", @@ -1656,20 +1666,19 @@ "attackCase": "Eset támadása", "brainstormIdeas": "Ötletelés", "findEvidence": "Bizonyíték keresése", - "sparringTitle": "Vitapárbaj fordulók", - "emptySparring": "Még nincs vitapárbaj forduló", + "sparringTitle": "Vitapárbaj-fordulók", + "emptySparring": "Még nincs vitapárbaj-forduló", "emptySparringDesc": "Fejezz be egy időzített vitafordulót, hogy itt átnézhesd.", "startSparring": "Vitapárbaj indítása", "score": "Te {{user}} - MI {{ai}}", - "deleteSparringAria": "Vitapárbaj forduló törlése: {{motion}}", - "deleteSparringTitle": "Törlöd a vitapárbaj fordulót?", + "deleteSparringAria": "Vitapárbaj-forduló törlése: {{motion}}", + "deleteSparringTitle": "Törlöd a vitapárbaj-fordulót?", "deleteSparringDesc": "Ez eltávolítja az értékelőlapot és az átiratot az előzményeidből.", - "sparringDeleted": "A vitapárbaj forduló törölve.", - "sparringDeleteFailed": "Nem sikerült törölni a vitapárbaj fordulót.", + "sparringDeleted": "A vitapárbaj-forduló törölve.", + "sparringDeleteFailed": "Nem sikerült törölni a vitapárbaj-fordulót.", "deleteCaseLabel": "{{title}} törlése", - "load": "Betöltés", + "load": "Megnyitás", "resume": "Folytatás", - "messages": "üzenet", "caseDeleted": "A mentett eset törölve.", "deleteCaseDesc": "Ezzel eltávolítod a mentett esetet az előzményeidből.", "deleteCaseTitle": "Törlöd a mentett esetet?", @@ -1705,7 +1714,6 @@ "chooseFile": "Fájl választása", "importPreview": "Import előnézet", "saveImported": "Importált forrás mentése", - "words": "szó", "folder": "Mappa", "generalFolder": "Általános", "textSource": "Szöveg", @@ -1732,7 +1740,7 @@ "importFailed": "Import sikertelen", "importFailedDesc": "Nem sikerült importálni ezt a forrást.", "duplicateSource": "A forrás már a könyvtárban van", - "duplicateSourceDesc": "Ez a URL vagy fájl már mentve van.", + "duplicateSourceDesc": "Ez az URL vagy fájl már mentve van.", "fileReadFailed": "Nem sikerült beolvasni ezt a fájlt.", "saveToLibrary": "Mentés a könyvtárba", "addNewTitle": "Új forrás hozzáadása", @@ -1743,14 +1751,13 @@ "titlePlaceholder": "Forrás címe...", "originPlaceholder": "URL vagy hivatkozás...", "contentPlaceholder": "Illeszd be a forrás tartalmát...", - "sourcesCount": "forrás", "emptyTitle": "Még nincsenek források", - "emptyDesc": "Adj hozzá forrásokat az MI válaszok valós bizonyítékokkal való megalapozásához.", + "emptyDesc": "Adj hozzá forrásokat az MI-válaszok valós bizonyítékokkal való megalapozásához.", "manualEntry": "Kézi bevitel", "groundingActive": "Megalapozás aktív", "groundingDisabled": "Megalapozás letiltva", - "groundingActiveDesc": "Az MI válaszok használják a forrásaidat", - "groundingDisabledDesc": "Az MI válaszok nincsenek megalapozva", + "groundingActiveDesc": "Az MI-válaszok használják a forrásaidat", + "groundingDisabledDesc": "Az MI-válaszok nincsenek megalapozva", "clearFailed": "Nem sikerült törölni a forrásokat", "clearConfirm": "Biztosan törölni szeretnéd az összes forrást?", "importUrlHelp": "Az automatikus importálás szerkeszthető piszkozatot hoz létre. Nézd át, mielőtt a könyvtárba mented.", @@ -1787,7 +1794,7 @@ "restoreFailed": "Nem sikerült visszaállítani a forrást", "deleteFailed": "Nem sikerült törölni a forrást.", "readingFile": "Fájl beolvasása…", - "summary": "{{total}} forrás · {{active}} aktív · {{folders}} mappa", + "summary": "{{sources}} · {{active}} · {{folders}}", "mergeFolderTitle": "Összevonod ezzel: {{name}}?", "mergeFolders": "Mappák összevonása", "mergeFailed": "Nem sikerült összevonni a mappákat. Próbáld újra.", @@ -1800,7 +1807,15 @@ "sourceSaved": "A(z) {{title}} bekerült a könyvtárba", "duplicateUrlDesc": "Ez az oldal már szerepel ennek a vitának a könyvtárában.", "loadingSources": "Források betöltése…", - "folderUpdateFailed": "Nem sikerült módosítani ezt a mappát" + "folderUpdateFailed": "Nem sikerült módosítani ezt a mappát", + "sourceCount_one": "{{count}} forrás", + "sourceCount_other": "{{count}} forrás", + "activeCount_one": "{{count}} aktív", + "activeCount_other": "{{count}} aktív", + "folderCount_one": "{{count}} mappa", + "folderCount_other": "{{count}} mappa", + "wordCount_one": "{{formatted}} szó", + "wordCount_other": "{{formatted}} szó" }, "autoSavedSources": { "undo": "Automatikus források visszavonása", @@ -1824,7 +1839,8 @@ "goToApp": "Ugrás az appba", "openApp": "App megnyitása", "sharedOn": "Megosztva", - "views": "megtekintés" + "viewCount_one": "{{count}} megtekintés", + "viewCount_other": "{{count}} megtekintés" }, "privacyPage": { "title": "Adatvédelmi irányelvek", @@ -1862,7 +1878,7 @@ "s2": "Szolgáltatás leírása", "p2": "A DebateLab egy MI-alapú vitafelkészítő eszköz, amely érvgenerálást, elemzést és coaching funkciókat biztosít.", "s3": "Fiókkövetelmények", - "p3": "Google-lal, e-mailben kapott bejelentkezési hivatkozással vagy adminisztrátor által kiadott demókóddal jelentkezhetsz be. A demókódok ideiglenes hozzáférést adnak egy kijelölt fiókhoz. Felelős vagy a fiókod alatt végzett minden tevékenységért.", + "p3": "Google-lal, e-mailben kapott hatjegyű kóddal vagy adminisztrátor által kiadott demókóddal jelentkezhetsz be. A demókódok ideiglenes hozzáférést adnak egy kijelölt fiókhoz. Felelős vagy a fiókod alatt végzett minden tevékenységért.", "s4": "Előfizetés és számlázás", "p4": "Az ingyenes próba 3 napig tart korlátozott kérésekkel. A Pro előfizetés $5/hó plusz adó, Stripe-on keresztül számlázva. Bármikor lemondható a Beállításokból.", "s5": "Elfogadható használat", @@ -1941,7 +1957,7 @@ "side": "Oldal", "proposition": "Mellette", "opposition": "Ellene", - "caseAttack": "Eset támadás elemzés", + "caseAttack": "Esettámadás-elemzés", "overallScore": "Összesített pontszám", "whyWeak": "Miért gyenge", "howOpponentAttacks": "Hogyan támadja az ellenfél", @@ -1958,13 +1974,13 @@ "source": "Forrás", "noFactoids": "Nem találhatók tények.", "exordio": "Exordio", - "caseStructure": "Eset struktúra", + "caseStructure": "Esetstruktúra", "thesis": "Tézis", "framework": "Keretrendszer", "burdenOfProof": "Bizonyítási teher", "keyDefinitions": "Kulcsdefiníciók", "arguments": "Érvek", - "predictedOpponent": "Várható ellenfél eset", + "predictedOpponent": "Az ellenfél várható esete", "counters": "Előkészített ellenérvek", "weaknesses": "Azonosított gyengeségek", "fallacies": "Észlelt téves következtetések", @@ -1978,7 +1994,7 @@ "sparringTimeUsed": "Felhasznált idő", "sparringTotals": "Végeredmény", "sparringUser": "Te", - "sparringOpponent": "MI ellenfél", + "sparringOpponent": "MI-ellenfél", "sparringWinner": "Győztes", "sparringTie": "Döntetlen", "sparringScore": "Pontszám", @@ -2000,26 +2016,26 @@ "pro": "Mellette", "con": "Ellene", "user": "Te", - "opponent": "MI ellenfél", + "opponent": "MI-ellenfél", "format": { "standard": "Iskolai 4-6-6-3", "ld": "Lincoln-Douglas", "pf": "Public Forum" }, "formatDescription": { - "standard": "Az iskolai formátum: Introductor, R1, R2 és Conclusor mindkét oldalon.", + "standard": "Az iskolai formátum: bevezető, R1, R2 és záró beszéd mindkét oldalon.", "ld": "Értékalapú forduló konstruktív beszédekkel és két cáfolati szakasszal.", "pf": "Konstruktív beszédek, cáfolatok, összegzések és végső fókusz mindkét oldalnak." }, "turns": { - "standard-1": "Pro Introductor", - "standard-2": "Kontra Introductor", + "standard-1": "Pro bevezető", + "standard-2": "Kontra bevezető", "standard-3": "Pro R1", "standard-4": "Kontra R1", "standard-5": "Pro R2", "standard-6": "Kontra R2", - "standard-7": "Pro Conclusor", - "standard-8": "Kontra Conclusor", + "standard-7": "Pro záró", + "standard-8": "Kontra záró", "ld-1": "Affirmatív érvelés", "ld-2": "Negatív érvelés", "ld-3": "Első affirmatív cáfolat", @@ -2095,7 +2111,7 @@ "preparingResult": "Eredmény előkészítése", "judgeReading": "A bíró olvassa az átiratot.", "scoring": "Forduló értékelése", - "noTimer": "Értékelés közben nincs óra", + "noTimer": "Értékelés közben nincs időmérő", "timeRemaining": "{{time}} van hátra", "pauseClock": "Óra szüneteltetése", "resumeClock": "Óra folytatása", @@ -2186,7 +2202,7 @@ "label": "Nyelv", "title": "Nyelv", "uiLanguage": "Felület nyelve", - "outputLanguage": "AI kimenet nyelve" + "outputLanguage": "MI-kimenet nyelve" }, "crossfire": { "openFromResult": "Megnyitás a Keresztkérdezésben", @@ -2358,7 +2374,7 @@ "deleted": "Vita törölve", "deleteFailed": "Nem sikerült törölni a vitát", "lastActivity": "Utolsó tevékenység: {{when}}", - "counts": "{{sources}} forrás ({{active}} aktív) · {{outputs}} eredmény", + "counts": "{{sources}} ({{active}}) · {{outputs}}", "menuAria": "Aktuális vita: {{motion}}", "open": "Vita megnyitása", "useOneOff": "Egyszeri mód használata", @@ -2410,20 +2426,20 @@ "neutral": "Semleges" }, "kinds": { - "case": "Esetépítés", + "case": "Eset", "attack": "Támadás", "brainstorm": "Ötletelés", "factoids": "Tények", "chat": "Kérdező csevegés", "flowchart": "Folyamatábra", "crossfire": "Keresztkérdezés", - "sparring": "Edzőmeccs", + "sparring": "Vitapárbaj", "source": "Forrás" }, "formats": { "custom": "Egyéni", - "bp": "Brit parlamenti", - "policy": "Szakpolitikai", + "bp": "British Parliamentary", + "policy": "Policy", "classroom": "Tantermi" }, "offerTitle": "Indítasz egy vitát ezzel a tétellel?", @@ -2435,7 +2451,13 @@ "switching": "Váltás…", "deleting": "Törlés…", "creating": "Létrehozás…", - "loadingCurrent": "Az aktuális vitád betöltése…" + "loadingCurrent": "Az aktuális vitád betöltése…", + "activeCount_one": "{{count}} aktív", + "activeCount_other": "{{count}} aktív", + "outputCount_one": "{{count}} eredmény", + "outputCount_other": "{{count}} eredmény", + "sourceCount_one": "{{count}} forrás", + "sourceCount_other": "{{count}} forrás" }, "debateContext": { "using": "Felhasználva: {{list}}", @@ -2454,7 +2476,7 @@ "instructionsUsed": "A vita beállításai és utasításai benne vannak.", "instructionsNotUsed": "A vita beállításai benne vannak. A szabad szöveges utasításokat itt nem használjuk, így nem módosíthatják a gyakorlat menetét.", "sourcesHeading": "Könyvtári források", - "sparringNoSources": "Az élő edzőmeccsek csak az átiratot, a témát és a vita beállításait használják.", + "sparringNoSources": "Az élő vitapárbajok csak az átiratot, a témát és a vita beállításait használják.", "sourcesOff": "A forráskönyvtár ki van kapcsolva ennél a kérésnél.", "noSources": "Ehhez a vitához még nincs forrás a könyvtárban.", "resultsHeading": "Mentett eredmények", @@ -2557,7 +2579,7 @@ }, "shortAnswer": { "question": "Melyik a legjobb MI-alapú vitagyakorló eszköz?", - "answer": "Attól függ, mire van szükséged. {{asOf}} állapot szerint a DebateLab azoknak a diákoknak és versenyvitázóknak való, akik egy helyen szeretnének eseteket építeni, gyenge pontokat találni, bizonyítékokat ellenőrizni és időzített gyakorlófordulókat futtatni angolul, spanyolul vagy magyarul: 3 napos ingyenes próba bankkártya nélkül, utána $5/hó + adó. Ha ingyen szeretnél MI-ellenféllel gyakorolni World Schools vagy ázsiai parlamentáris stílusban, a debatelab.ai, egy másik termék, azt írja magáról, hogy ingyenes. Ha főleg ötletelésre vagy írási segítségre van szükséged bármilyen témában, egy általános asszisztens, például a ChatGPT rugalmasabb.", + "answer": "Attól függ, mire van szükséged. {{asOf}} állapot szerint a DebateLab azoknak a diákoknak és versenyvitázóknak való, akik egy helyen szeretnének eseteket építeni, gyenge pontokat találni, bizonyítékokat ellenőrizni és időzített gyakorlófordulókat futtatni angolul, spanyolul vagy magyarul: 3 napos ingyenes próba bankkártya nélkül, utána $5/hó + adó. Ha ingyen szeretnél MI-ellenféllel gyakorolni World Schools vagy Asian Parliamentary stílusban, a debatelab.ai, egy másik termék, azt írja magáról, hogy ingyenes. Ha főleg ötletelésre vagy írási segítségre van szükséged bármilyen témában, egy általános asszisztens, például a ChatGPT rugalmasabb.", "compare": "MI-alapú vitaeszközök összehasonlítása" }, "sessionQuota": { @@ -2604,12 +2626,12 @@ "description": "Ez az oldal nem létezik a {{brand}} webhelyén." }, "tools": { - "title": "Ingyenes vitaeszközök: időzítő, indítványok, jegyzetlapok | {{brand}}", - "description": "Ingyenes, fiók nélkül használható vitaeszközök: formátumhoz igazodó időzítő, indítványgenerátor, nyomtatható jegyzetlapok és beszédidő-kalkulátor." + "title": "Ingyenes vitaeszközök: időmérő, indítványok, jegyzetlapok | {{brand}}", + "description": "Ingyenes, fiók nélkül használható vitaeszközök: formátumhoz igazodó időmérő, indítványgenerátor, nyomtatható jegyzetlapok és beszédidő-kalkulátor." }, "debateTimer": { - "title": "Ingyenes vitaidőzítő PF, LD, Policy, BP és World Schools formátumhoz | {{brand}}", - "description": "Ingyenes vitaidőzítő PF, LD, Policy, BP, World Schools és Asian Parliamentary beállításokkal: védett idő, POI-jelzés és felkészülési idő." + "title": "Ingyenes vitaidőmérő PF, LD, Policy, BP és World Schools formátumhoz | {{brand}}", + "description": "Ingyenes vitaidőmérő PF, LD, Policy, BP, World Schools és Asian Parliamentary beállításokkal: védett idő, POI-jelzés és felkészülési idő." }, "motionGenerator": { "title": "Indítványgenerátor: 300 gyakorló vitaindítvány | {{brand}}", diff --git a/src/locales/tools/es.json b/src/locales/tools/es.json index 33b2e3196..b684b09d7 100644 --- a/src/locales/tools/es.json +++ b/src/locales/tools/es.json @@ -116,7 +116,7 @@ "clearFilters": "Quitar filtros", "empty": "Pulsa Sacar una moción para obtener una, o Sacar tres para un ejercicio de veto.", "optionN": "Opción {{n}}", - "infoSlide": "Info:", + "infoSlide": "Información:", "copy": "Copiar moción", "copyLink": "Copiar enlace", "copied": "Copiado", diff --git a/src/locales/tools/hu.json b/src/locales/tools/hu.json index 1a3543774..78f9649af 100644 --- a/src/locales/tools/hu.json +++ b/src/locales/tools/hu.json @@ -121,7 +121,7 @@ "copyLink": "Link másolása", "copied": "Másolva", "copyPrompt": "Másold ki ezt a szöveget:", - "prepCta": "Készülj fel a tétel mindkét oldalára a DebateLabben", + "prepCta": "Készülj fel a tétel mindkét oldalára a DebateLabban", "topics": { "education": "Oktatás", "technology": "Technológia és MI", diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx index fa10bd7d4..7d44329b7 100644 --- a/src/pages/AdminPage.tsx +++ b/src/pages/AdminPage.tsx @@ -20,7 +20,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { formatEuropeanDate, formatEuropeanDateTime, formatNumber } from '@/lib/date-format'; +import { formatDecimal, formatEuropeanDate, formatEuropeanDateTime, formatNumber } from '@/lib/date-format'; import { captureProductEvent } from '@/lib/observability'; import { AdminAnnouncements } from '@/components/admin/AdminAnnouncements'; @@ -463,11 +463,11 @@ export default function AdminPage() { const { t } = useTranslation(); const formatKnownCost = (cost: number, unknownCostRequests = 0) => { if (cost > 0 && unknownCostRequests > 0) { - return t('admin.dashboard.knownPlusUnknownCost', { cost: cost.toFixed(4), count: unknownCostRequests }); + return t('admin.dashboard.knownPlusUnknownCost', { cost: formatDecimal(cost, 4), count: unknownCostRequests }); } - if (cost > 0) return `$${cost.toFixed(4)}`; + if (cost > 0) return `$${formatDecimal(cost, 4)}`; if (unknownCostRequests > 0) return t('admin.dashboard.unknownCost'); - return '$0.0000'; + return `$${formatDecimal(0, 4)}`; }; const fetchAdminData = useCallback(async (action: string, params?: Record) => { @@ -798,7 +798,7 @@ export default function AdminPage() { -
{stats?.totals.successRate.toFixed(1) || 100}%
+
{stats ? formatDecimal(stats.totals.successRate, 1) : 100}%

{t('admin.dashboard.successRateDescription')}

diff --git a/src/pages/CurrentDebatePage.tsx b/src/pages/CurrentDebatePage.tsx index 63fb6288b..108c3c20a 100644 --- a/src/pages/CurrentDebatePage.tsx +++ b/src/pages/CurrentDebatePage.tsx @@ -9,7 +9,7 @@ import { EmptyState } from '@/components/ui/empty-state'; import { PageHeader } from '@/components/layout/PageHeader'; import { PRACTICE_TOOLS, PREPARE_TOOLS, ToolLink } from '@/components/features/tools/tool-links'; import { DebateForm } from '@/components/features/debate/DebateForm'; -import { sideLabelKey, useDebateCounts } from '@/components/features/debate/CurrentDebateStrip'; +import { debateCountsLabel, sideLabelKey, useDebateCounts } from '@/components/features/debate/CurrentDebateStrip'; import { useAllDebateItems, useOpenDebateItem, type DebateItem } from '@/components/features/debate/debate-items'; import { useToast } from '@/hooks/use-toast'; import { formatEuropeanDateTime, formatRelativeDate } from '@/lib/date-format'; @@ -110,7 +110,7 @@ export default function CurrentDebatePage() { meta={<> {t('debate.lastActivity', { when: formatRelativeDate(debate.lastActivityAt), defaultValue: 'Last activity {{when}}' })} {' · '} - {t('debate.counts', { sources: sourceCounts.sources, active: sourceCounts.activeSources, outputs: items.length, defaultValue: '{{sources}} sources ({{active}} active) · {{outputs}} outputs' })} + {debateCountsLabel(t, { ...sourceCounts, outputs: items.length })} } actions={<>
diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index ccaed2f5c..8819f13da 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DebateRequiredError, DuplicateSourceError, isPendingSource, useSessionStore, type Source } from '@/lib/stores/session-store'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -10,7 +10,7 @@ import { Trash2, Plus, Globe, FileText, Database, ShieldCheck, ShieldAlert, Exte import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; import { useToast } from '@/hooks/use-toast'; -import { formatEuropeanDate } from '@/lib/date-format'; +import { formatEuropeanDate, formatNumber } from '@/lib/date-format'; import { importSourceFromUrl } from '@/lib/source-import'; import { findSavedPage } from '@/lib/source-url'; import { validateUrl } from '@/lib/validation/url'; @@ -250,6 +250,7 @@ export default function LibraryPage() { folder: 'General', isActive: true, }); + const draftWordCount = useMemo(() => sourceDraft.content.trim().split(/\s+/).filter(Boolean).length, [sourceDraft.content]); const [importPreview, setImportPreview] = useState<{ title: string; content: string; @@ -688,7 +689,11 @@ export default function LibraryPage() {

- {t('libraryPage.summary', { total: sourceLibrary.length, active: activeSourceCount, folders: folders.length, defaultValue: '{{total}} sources · {{active}} active · {{folders}} folders' })} + {t('libraryPage.summary', { + sources: t('libraryPage.sourceCount', { count: sourceLibrary.length }), + active: t('libraryPage.activeCount', { count: activeSourceCount }), + folders: t('libraryPage.folderCount', { count: folders.length }), + })}

) : ( @@ -846,7 +851,7 @@ export default function LibraryPage() {

{t('libraryPage.importPreview')}

- {[importPreview.domain || importPreview.fileType || importPreview.origin, importPreview.wordCount ? `${importPreview.wordCount} ${t('libraryPage.words')}` : null].filter(Boolean).join(' • ')} + {[importPreview.domain || importPreview.fileType || importPreview.origin, importPreview.wordCount ? t('libraryPage.wordCount', { count: importPreview.wordCount, formatted: formatNumber(importPreview.wordCount) }) : null].filter(Boolean).join(' • ')}

@@ -1160,7 +1165,7 @@ export default function LibraryPage() {
{sourceDraft.title || t('libraryPage.untitledSource', 'Untitled source')} - {[getFolderName(sourceDraft.folder || 'General'), formatEuropeanDate(selectedSource.createdAt), `${sourceDraft.content.trim().split(/\s+/).filter(Boolean).length} ${t('libraryPage.words')}`].join(' • ')} + {[getFolderName(sourceDraft.folder || 'General'), formatEuropeanDate(selectedSource.createdAt), t('libraryPage.wordCount', { count: draftWordCount, formatted: formatNumber(draftWordCount) })].join(' • ')}
diff --git a/src/pages/SharedPage.tsx b/src/pages/SharedPage.tsx index 6fbb00c8f..2a4b93c7f 100644 --- a/src/pages/SharedPage.tsx +++ b/src/pages/SharedPage.tsx @@ -174,7 +174,7 @@ export default function SharedPage() {

{item.item_type} {t('sharedPage.sharedOn')} {formatEuropeanDate(item.created_at)} - • {item.view_count} {t('sharedPage.views')} + • {t('sharedPage.viewCount', { count: item.view_count })}

{result ?? ( diff --git a/src/test/a11y/names-and-headings.test.tsx b/src/test/a11y/names-and-headings.test.tsx index ef69bf381..5cbddb458 100644 --- a/src/test/a11y/names-and-headings.test.tsx +++ b/src/test/a11y/names-and-headings.test.tsx @@ -79,7 +79,7 @@ describe('accessible names and headings', () => { rerender(); expect(screen.getByRole('button', { name: 'Nyelv' })).toBeInTheDocument(); expect(screen.getByRole('radiogroup', { name: 'Felület nyelve' })).toBeInTheDocument(); - expect(screen.getByRole('radiogroup', { name: 'AI kimenet nyelve' })).toBeInTheDocument(); + expect(screen.getByRole('radiogroup', { name: 'MI-kimenet nyelve' })).toBeInTheDocument(); }); it('names Generate selects from their visible labels', () => { diff --git a/src/test/components/LimitReachedModal.test.tsx b/src/test/components/LimitReachedModal.test.tsx index 4ad1b3a7d..30fae3b42 100644 --- a/src/test/components/LimitReachedModal.test.tsx +++ b/src/test/components/LimitReachedModal.test.tsx @@ -101,7 +101,7 @@ describe('LimitReachedModal', () => { subscription.billingDate = '2026-10-01T12:00:00.000Z'; const { onUpgrade } = renderModal('subscription_ended'); expect(screen.getByRole('heading', { name: 'Pro Subscription Ended' })).toBeInTheDocument(); - expect(screen.getByText(/Your Pro subscription ended on 01\/10\/2026/)).toBeInTheDocument(); + expect(screen.getByText(/Your Pro subscription ended on 1 Oct 2026/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Resubscribe' })); expect(onUpgrade).toHaveBeenCalled(); }); diff --git a/src/test/components/SubscriptionStatus.test.tsx b/src/test/components/SubscriptionStatus.test.tsx index 926902480..55a3ebc7f 100644 --- a/src/test/components/SubscriptionStatus.test.tsx +++ b/src/test/components/SubscriptionStatus.test.tsx @@ -30,7 +30,7 @@ const CASES: Array<{ text: RegExp; button: string; }> = [ - { name: 'trial', props: { billingState: 'trial', billingDate: DATE, billingDateKind: 'ends' }, badge: 'Free Trial', date: 'Ends on 22/11/2026', text: /AI access is active/, button: 'Upgrade to Pro' }, + { name: 'trial', props: { billingState: 'trial', billingDate: DATE, billingDateKind: 'ends' }, badge: 'Free Trial', date: 'Ends on 22 Nov 2026', text: /AI access is active/, button: 'Upgrade to Pro' }, { // QB-02: an override on an expired trial works, so it is not "Expired". name: 'extended access', @@ -44,7 +44,7 @@ const CASES: Array<{ name: 'Pro renewing', props: { billingState: 'pro', billingDate: DATE, billingDateKind: 'renews', hasOpenSubscription: true, hasStripeBilling: true, dailyLimit: 50 }, badge: 'Pro', - date: 'Renews on 22/11/2026', + date: 'Renews on 22 Nov 2026', text: /AI access is active/, button: 'Manage Subscription', }, @@ -53,7 +53,7 @@ const CASES: Array<{ name: 'Pro set to cancel', props: { billingState: 'pro_canceling', billingDate: DATE, billingDateKind: 'cancels', hasOpenSubscription: true, hasStripeBilling: true, dailyLimit: 50 }, badge: 'Pro', - date: 'Cancels on 22/11/2026', + date: 'Cancels on 22 Nov 2026', text: /AI access is active/, button: 'Manage Subscription', }, @@ -63,7 +63,7 @@ const CASES: Array<{ props: { billingState: 'payment_failed', billingDate: DATE, billingDateKind: 'grace_ends', hasOpenSubscription: true, hasStripeBilling: true, dailyLimit: 50 }, badge: 'Payment failed', date: null, - text: /Payment failed: update your payment method to keep Pro\. Pro access continues until 22\/11\/2026/, + text: /Payment failed: update your payment method to keep Pro\. Pro access continues until 22 Nov 2026/, button: 'Manage Subscription', }, { @@ -80,7 +80,7 @@ const CASES: Array<{ props: { billingState: 'subscription_ended', billingDate: DATE, billingDateKind: 'ended', hasStripeBilling: true, dailyLimit: 0 }, badge: 'Expired', date: null, - text: /Your Pro subscription ended on 22\/11\/2026\. Resubscribe/, + text: /Your Pro subscription ended on 22 Nov 2026\. Resubscribe/, button: 'Resubscribe', }, { name: 'trial ended', props: { billingState: 'trial_ended', dailyLimit: 0 }, badge: 'Expired', date: null, text: /Your access has expired/, button: 'Upgrade to Pro' }, @@ -88,7 +88,7 @@ const CASES: Array<{ name: 'legacy free', props: { billingState: 'legacy_free', billingDate: DATE, billingDateKind: 'ends', dailyLimit: 50 }, badge: 'Legacy Free', - date: 'Ends on 22/11/2026', + date: 'Ends on 22 Nov 2026', text: /AI access is active/, button: 'Upgrade to Pro', }, diff --git a/src/test/date-format.test.ts b/src/test/date-format.test.ts new file mode 100644 index 000000000..f8e7a5e21 --- /dev/null +++ b/src/test/date-format.test.ts @@ -0,0 +1,35 @@ +import { beforeAll, expect, test } from 'vitest'; +import { loadAllAppLanguages } from '../i18n'; +import { formatDecimal, formatEuropeanDate, formatEuropeanDateTime, formatNumber } from '../lib/date-format'; + +beforeAll(() => loadAllAppLanguages()); + +const OCT_23 = new Date(2026, 9, 23, 14, 5); + +test('numbers group thousands in the active language, four-digit ones included', () => { + expect(`40/${formatNumber(1200, 'en')}`).toBe('40/1,200'); + expect(`40/${formatNumber(1200, 'es')}`).toBe('40/1.200'); + expect(`40/${formatNumber(1200, 'hu')}`).toBe('40/1 200'); + expect(formatNumber(40, 'es')).toBe('40'); +}); + +test('decimals use the language separator', () => { + expect(formatDecimal(1.25, 1, 'en')).toBe('1.3'); + expect(formatDecimal(1.25, 1, 'es')).toBe('1,3'); + expect(formatDecimal(1.25, 1, 'hu')).toBe('1,3'); + expect(formatDecimal(12, 0, 'hu')).toBe('12'); +}); + +test('dates name the month, so day and month cannot be confused', () => { + expect(formatEuropeanDate(OCT_23, undefined, 'en')).toBe('23 Oct 2026'); + expect(formatEuropeanDate(OCT_23, undefined, 'es')).toBe('23 oct 2026'); + expect(formatEuropeanDate(OCT_23, undefined, 'hu')).toBe('2026. okt. 23.'); + expect(formatEuropeanDateTime(OCT_23, undefined, 'en')).toBe('23 Oct 2026, 14:05'); +}); + +test('a missing date reads as not available in the language', () => { + expect(formatEuropeanDate('not a date', undefined, 'en')).toBe('N/A'); + expect(formatEuropeanDate('not a date', undefined, 'es')).toBe('N/D'); + expect(formatEuropeanDate('not a date', undefined, 'hu')).toBe('nincs adat'); + expect(formatEuropeanDate('not a date', '—', 'hu')).toBe('—'); +}); diff --git a/src/test/i18n-untranslated.test.ts b/src/test/i18n-untranslated.test.ts new file mode 100644 index 000000000..6ad3c560a --- /dev/null +++ b/src/test/i18n-untranslated.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from 'vitest'; +import en from '../locales/en.json'; +import es from '../locales/es.json'; +import hu from '../locales/hu.json'; +import toolsEn from '../locales/tools/en.json'; +import toolsEs from '../locales/tools/es.json'; +import toolsHu from '../locales/tools/hu.json'; + +/** + * Spanish and Hungarian values that are meant to read exactly as in English, + * as `language namespace:key`. Anything else identical to English was most + * likely never translated. See the glossary in docs/standards/ui.md. + */ +const SAME_AS_ENGLISH: Record = { + 'Product, plan and file-format names': [ + 'es translation:home.title', 'hu translation:home.title', + 'es translation:settings.tierPro', 'hu translation:settings.tierPro', + 'es translation:pricing.pro', 'hu translation:pricing.pro', + 'es translation:pricingModal.pro', 'hu translation:pricingModal.pro', + 'es translation:common.pdf', 'hu translation:common.pdf', + 'es translation:common.docx', 'hu translation:common.docx', + 'es translation:common.googleDocs', 'hu translation:common.googleDocs', + 'es translation:sparring.scorecard.exportPdf', 'hu translation:sparring.scorecard.exportPdf', + 'es translation:libraryPage.urlSource', 'hu translation:libraryPage.urlSource', + 'es translation:login.demoCodePlaceholder', 'hu translation:login.demoCodePlaceholder', + ], + 'Competition format names stay in English in every language, as on the free tools pages': [ + 'es translation:timer.formatLD', 'hu translation:timer.formatLD', + 'es translation:timer.formatPF', 'hu translation:timer.formatPF', + 'es translation:timer.formatPolicy', 'hu translation:timer.formatPolicy', + 'es translation:sparring.format.ld', 'hu translation:sparring.format.ld', + 'es translation:sparring.format.pf', 'hu translation:sparring.format.pf', + 'es translation:debate.formats.bp', 'hu translation:debate.formats.bp', + 'es translation:debate.formats.policy', 'hu translation:debate.formats.policy', + ], + 'Policy speech codes and school-format role codes': [ + ...['1ac', '1nc', '2ac', '2nc', '1nr', '1ar', '2nr', '2ar'].flatMap((code) => [ + `es translation:timer.speeches.policy.${code}`, `hu translation:timer.speeches.policy.${code}`, + ]), + 'hu translation:sparring.turns.standard-3', 'hu translation:sparring.turns.standard-5', + ], + 'Motion wording and paper size, named as printed on the sheet': [ + 'es tools:motions.wordingResolved', 'hu tools:motions.wordingResolved', + 'hu tools:motions.wordingHouse', + 'hu tools:flow.letter', + ], + 'Exordio, the rhetoric term, and unit abbreviations': [ + 'es translation:generateResults.exordio', 'hu translation:generateResults.exordio', + 'es translation:export.exordio', 'hu translation:export.exordio', + 'es translation:generateResults.durationMs', 'hu translation:generateResults.durationMs', + 'es translation:crossfire.duration3m', 'es translation:crossfire.duration5m', 'es translation:crossfire.duration8m', + ], + 'Sparring keeps its name in Spanish, where the word is used as is': [ + 'es translation:nav.sparring', 'es translation:home.sparring', 'es translation:hero.preview.modeSparring', + 'es translation:features.sparring', 'es translation:historyPage.tabSparring', 'es translation:debate.kinds.sparring', + ], + 'Spanish words spelled the same as the English ones': [ + 'es translation:common.error', 'es translation:ask.failed', 'es translation:settings.deleteFailed', + 'es translation:settings.portalError', 'es translation:admin.error', 'es translation:pricingModal.redeemError', + 'es translation:workspaces.original', 'es translation:login.emailTab', 'es translation:generateForm.toneFormal', + 'es translation:generateForm.roleIntro', 'es translation:generateForm.roleConc', + 'es translation:timer.speeches.standard.intro', 'es translation:timer.speeches.standard.conc', + 'es translation:brainstormResults.neutral', 'es translation:debate.side.neutral', + 'es translation:askChat.modeGeneral', 'es translation:libraryPage.generalFolder', 'es translation:sourceQuality.types.generic', + 'es translation:askChat.pros', 'es translation:historyPage.tabChats', 'es translation:sourceQuality.types.social', + 'es translation:debateContext.debateHeading', 'es tools:motions.types.actor', + ], + 'Hungarian words spelled the same as the English ones': [ + 'hu tools:motions.topics.sport', + ], +}; + +function flat(catalog: object, prefix = ''): [string, string][] { + return Object.entries(catalog).flatMap(([key, value]): [string, string][] => { + const path = prefix + key; + return typeof value === 'string' ? [[path, value]] : value && typeof value === 'object' ? flat(value, path + '.') : []; + }); +} + +function sameAsEnglish(): string[] { + const catalogs = { translation: { en, es, hu }, tools: { en: toolsEn, es: toolsEs, hu: toolsHu } }; + return Object.entries(catalogs).flatMap(([namespace, { en: english, ...others }]) => { + const englishValues = new Map(flat(english)); + return Object.entries(others).flatMap(([language, catalog]) => flat(catalog) + // Placeholders, numbers and punctuation alone need no translation. + .filter(([key, value]) => value === englishValues.get(key) && /\p{L}{2}/u.test(value.replace(/\{\{[^}]*\}\}/g, ''))) + .map(([key]) => `${language} ${namespace}:${key}`)); + }); +} + +test('Spanish and Hungarian values match English only where that is intended', () => { + const allowed = new Set(Object.values(SAME_AS_ENGLISH).flat()); + const found = sameAsEnglish(); + expect(found.filter((entry) => !allowed.has(entry))).toEqual([]); + // An entry that no longer matches English has been translated: drop it from the list. + expect([...allowed].filter((entry) => !found.includes(entry))).toEqual([]); +}); diff --git a/src/test/i18n.test.ts b/src/test/i18n.test.ts index a49e844c1..8361ec836 100644 --- a/src/test/i18n.test.ts +++ b/src/test/i18n.test.ts @@ -1,5 +1,7 @@ import { expect, test } from 'vitest'; import i18n, { applyAppLanguage } from '../i18n'; +import { debateCountsLabel } from '../components/features/debate/CurrentDebateStrip'; +import type { AppLanguage } from '../lib/languages'; import en from '../locales/en.json'; import es from '../locales/es.json'; import hu from '../locales/hu.json'; @@ -26,6 +28,21 @@ test('Spanish and Hungarian free-tools catalogs match English keys', () => { expect(keys(toolsHu).sort()).toEqual(english); }); +test('count strings take the plural form of their count in every language', async () => { + const expected = { + en: ['1 source', '2 sources', '1 source (1 active) · 2 outputs'], + es: ['1 fuente', '2 fuentes', '1 fuente (1 activa) · 2 resultados'], + hu: ['1 forrás', '2 forrás', '1 forrás (1 aktív) · 2 eredmény'], + }; + for (const [language, [one, two, counts]] of Object.entries(expected) as [AppLanguage, string[]][]) { + await applyAppLanguage(language); + expect(i18n.t('libraryPage.sourceCount', { count: 1 })).toBe(one); + expect(i18n.t('libraryPage.sourceCount', { count: 2 })).toBe(two); + expect(debateCountsLabel(i18n.t, { sources: 1, activeSources: 1, outputs: 2 })).toBe(counts); + } + await applyAppLanguage('en'); +}); + test('a later language choice wins over one whose strings are still loading', async () => { // Spanish has to be fetched; English is already there and applies at once. const spanish = applyAppLanguage('es');