diff --git a/Dockerfile b/Dockerfile index 0b5962094..e1bf949a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,6 +71,8 @@ ENV PORT=3000 COPY --from=server-builder --chown=node:node /deploy ./ COPY --from=client-builder --chown=node:node /app/client/dist ./public +# The server reads its own translated text (achievements, monthly challenges) from the web's catalogs. +COPY --from=client-builder --chown=node:node /app/client/src/locales ./locales COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh /app/server/file-env.sh ./ COPY --chown=node:node LICENSE NOTICE ADDITIONAL_TERMS.md ./ COPY --chown=node:node server/bin/kepubify/ ./bin/kepubify/ diff --git a/client/src/lib/__tests__/api.spec.ts b/client/src/lib/__tests__/api.spec.ts index 865c67dca..aef5789d2 100644 --- a/client/src/lib/__tests__/api.spec.ts +++ b/client/src/lib/__tests__/api.spec.ts @@ -10,6 +10,7 @@ import { setAccessToken, setOnAuthFailure, } from '@/lib/api' +import { setI18nLocale } from '@/i18n' /** A token shaped like a real JWT, so the client can read `exp` out of it. Never verified here. */ function signedToken(expiresInSeconds: number): string { @@ -155,6 +156,37 @@ describe('api wrapper', () => { }) }) + describe('language', () => { + afterEach(async () => { + await setI18nLocale('en') + }) + + it('asks for server text in the language picked in Settings', async () => { + const languages: (string | null)[] = [] + globalThis.fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + languages.push(new Headers(init?.headers).get('Accept-Language')) + return Promise.resolve(new Response('', { status: 200 })) + }) as never + await setI18nLocale('nl') + + await api('/api/v1/achievements') + + expect(languages).toEqual(['nl']) + }) + + it('keeps an Accept-Language the caller set itself', async () => { + const languages: (string | null)[] = [] + globalThis.fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + languages.push(new Headers(init?.headers).get('Accept-Language')) + return Promise.resolve(new Response('', { status: 200 })) + }) as never + + await api('/api/v1/achievements', { headers: { 'Accept-Language': 'de' } }) + + expect(languages).toEqual(['de']) + }) + }) + describe('expiry awareness', () => { it('renews an expired token before the request, so the server never sees it', async () => { const calls: { url: string; auth: string | null }[] = [] diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index f2a53ba47..f88d5e83c 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -166,6 +166,9 @@ export async function fetchWithAuthProxyRecovery(input: RequestInfo | URL, init? async function rawFetch(input: RequestInfo | URL, init?: RequestInit): Promise { const headers = new Headers(init?.headers) if (_accessToken) headers.set('Authorization', `Bearer ${_accessToken}`) + // Text the server writes (achievement names, the monthly challenge) follows the language picked + // in Settings, not the browser's, so the page never mixes two. + if (!headers.has('Accept-Language')) headers.set('Accept-Language', i18n.global.locale.value) try { return await fetchWithAuthProxyRecovery(input, { ...init, headers, credentials: 'include' }) } catch (reason) { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 3dbfcd039..f9e7d5022 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -15589,5 +15589,394 @@ "tab": "tab" }, "resultCount": "{count, plural, =0 {No results} one {# result} other {# results}}" + }, + "serverText": { + "achievements": { + "books_finished_1": { + "name": "Ink Initiate", + "description": "Finish your first book" + }, + "books_finished_2": { + "name": "Spine Scout", + "description": "Finish 10 books" + }, + "books_finished_3": { + "name": "Story Stalwart", + "description": "Finish 35 books" + }, + "books_finished_4": { + "name": "Grand Archivist", + "description": "Finish 100 books" + }, + "pages_read_1": { + "name": "First Leaves", + "description": "Read 500 pages" + }, + "pages_read_2": { + "name": "Chapter Weaver", + "description": "Read 5,000 pages" + }, + "pages_read_3": { + "name": "Tome Tactician", + "description": "Read 25,000 pages" + }, + "pages_read_4": { + "name": "Grand Lexicon", + "description": "Read 100,000 pages" + }, + "hours_read_1": { + "name": "Hour Initiate", + "description": "Read for 10 hours" + }, + "hours_read_2": { + "name": "Timebound Reader", + "description": "Read for 100 hours" + }, + "hours_read_3": { + "name": "Chronicle Veteran", + "description": "Read for 500 hours" + }, + "hours_read_4": { + "name": "Timeless Scholar", + "description": "Read for 1,000 hours" + }, + "marathoner": { + "name": "Marathoner", + "description": "Complete a reading session of 90 minutes or more" + }, + "all_nighter": { + "name": "All-nighter", + "description": "Read between 1am and 4am" + }, + "early_bird": { + "name": "Early Bird", + "description": "Start a reading session between 5am and 7am" + }, + "long_book_1": { + "name": "Story Stretch", + "description": "Finish a book with 300 or more pages" + }, + "page_turner": { + "name": "Page Pathfinder", + "description": "Finish a book with 500 or more pages" + }, + "long_book_3": { + "name": "Epic Voyager", + "description": "Finish a book with 750 or more pages" + }, + "tome_tamer": { + "name": "Tome Sovereign", + "description": "Finish a book with 1,000 or more pages" + }, + "sprint": { + "name": "Sprint", + "description": "Finish a book in a single day" + }, + "binge_reader": { + "name": "Binge Reader", + "description": "Finish 3 or more books in a single week" + }, + "library_builder_1": { + "name": "Shelf Seed", + "description": "Have 50 books in your library" + }, + "library_builder_2": { + "name": "Collection Architect", + "description": "Have 250 books in your library" + }, + "library_builder_3": { + "name": "Stack Sovereign", + "description": "Have 1,000 books in your library" + }, + "library_builder_4": { + "name": "Library of Alexandria", + "description": "Have 5,000 books in your library" + }, + "annotator_1": { + "name": "Note Taker", + "description": "Create 10 annotations" + }, + "annotator_2": { + "name": "Active Annotator", + "description": "Create 50 annotations" + }, + "annotator_3": { + "name": "Prolific Annotator", + "description": "Create 200 annotations" + }, + "annotator_4": { + "name": "Master Annotator", + "description": "Create 500 annotations" + }, + "multi_format": { + "name": "Multi-Format", + "description": "Own books in 3 or more different formats" + }, + "curator": { + "name": "Curator", + "description": "Create 10 or more collections" + }, + "genre_explorer_1": { + "name": "Genre Curious", + "description": "Finish books in 5 different genres" + }, + "genre_explorer_2": { + "name": "Genre Adventurer", + "description": "Finish books in 10 different genres" + }, + "genre_explorer_3": { + "name": "Genre Connoisseur", + "description": "Finish books in 15 different genres" + }, + "genre_explorer_4": { + "name": "Genre Master", + "description": "Finish books in 20 different genres" + }, + "polyglot_1": { + "name": "Bilingual", + "description": "Finish books in 2 different languages" + }, + "polyglot_2": { + "name": "Trilingual", + "description": "Finish books in 3 different languages" + }, + "polyglot_3": { + "name": "Multilingual", + "description": "Finish books in 5 different languages" + }, + "polyglot_4": { + "name": "Polyglot", + "description": "Finish books in 7 different languages" + }, + "old_soul": { + "name": "Old Soul", + "description": "Finish a book published before 1900" + }, + "new_release": { + "name": "New Release", + "description": "Finish a book published in the current year" + }, + "century_span": { + "name": "Century Span", + "description": "Finish books from 3 or more different centuries" + }, + "author_deep_dive": { + "name": "Author Deep Dive", + "description": "Read 5 or more books by the same author" + }, + "streak_1": { + "name": "Getting Started", + "description": "Maintain a 7-day reading streak" + }, + "streak_2": { + "name": "Habitual", + "description": "Maintain a 30-day reading streak" + }, + "streak_3": { + "name": "Unstoppable", + "description": "Maintain a 100-day reading streak" + }, + "streak_4": { + "name": "Year-Long Flame", + "description": "Maintain a 365-day reading streak" + }, + "year_round_reader": { + "name": "Year-Round Reader", + "description": "Read in all 12 months of a calendar year" + }, + "reborn": { + "name": "Reborn", + "description": "Finish a book you had abandoned for over 6 months" + }, + "comeback_kid": { + "name": "Comeback Kid", + "description": "Resume reading after a 30-day break" + }, + "bookmarked": { + "name": "Bookmarked", + "description": "Create your first annotation or highlight" + }, + "new_year_reader": { + "name": "New Year Reader", + "description": "Complete a reading session on January 1st" + }, + "monthly_reader_2": { + "name": "Monthly Machine", + "description": "Finish 10 books in a single calendar month" + }, + "slow_burn": { + "name": "Slow Burn", + "description": "Take over 90 days from start to finish a book" + }, + "one_sitting": { + "name": "One Sitting", + "description": "Cover 90% or more of a book in a single reading session" + }, + "decade_sampler": { + "name": "Decade Sampler", + "description": "Finish books published in 5 different decades" + }, + "short_story_fan": { + "name": "Short Story Fan", + "description": "Finish 5 books with fewer than 100 pages" + }, + "thick_and_thin": { + "name": "Thick and Thin", + "description": "Finish at least one book under 100 pages and one over 800 pages" + }, + "trilogy_master": { + "name": "Trilogy Master", + "description": "Finish a complete 3-book series" + }, + "genre_devotee": { + "name": "Genre Devotee", + "description": "Finish 10 books in the same genre" + }, + "seasonal_reader": { + "name": "Four Seasons", + "description": "Read in all 4 seasons within a single year" + }, + "perfect_month": { + "name": "Perfect Month", + "description": "Read every day of a calendar month" + }, + "consistent_reader": { + "name": "Consistency Is Key", + "description": "Read at least 30 minutes every day for 14 consecutive days" + }, + "weekend_rhythm": { + "name": "Weekend Rhythm", + "description": "Read on 8 consecutive weekends" + }, + "weekend_warrior": { + "name": "Weekend Warrior", + "description": "Read for 5 or more hours in a single weekend" + }, + "yearly_finisher_12": { + "name": "Yearly Closer", + "description": "Finish 12 books in a single calendar year" + }, + "note_keeper": { + "name": "Note Keeper", + "description": "Create 25 annotations that include a note" + }, + "deep_dive_session": { + "name": "Deep Dive", + "description": "Create 10 annotations in a single day" + }, + "first_series": { + "name": "Series Begun", + "description": "Finish your first book that belongs to a named series" + }, + "category_sweeper": { + "name": "Category Sweeper", + "description": "Earn at least one achievement in all 4 categories" + }, + "power_hour": { + "name": "Power Hour", + "description": "Read 75 or more pages in a single reading session" + }, + "speed_reader": { + "name": "Speed Reader", + "description": "Read 100 or more pages in a single day" + }, + "old_friend": { + "name": "Old Friend", + "description": "Finish a book you were re-reading" + }, + "dnf_self_aware": { + "name": "It's Not You, It's Me", + "description": "Mark a book as abandoned" + }, + "first_verdict": { + "name": "First Verdict", + "description": "Rate your first book" + }, + "critic_1": { + "name": "Fledgling Critic", + "description": "Rate 10 books" + }, + "critic_2": { + "name": "Seasoned Critic", + "description": "Rate 50 books" + }, + "critic_3": { + "name": "Esteemed Critic", + "description": "Rate 200 books" + }, + "critic_4": { + "name": "Definitive Critic", + "description": "Rate 500 books" + }, + "standing_ovation": { + "name": "Standing Ovation", + "description": "Give a book a 5-star rating" + }, + "across_the_board": { + "name": "Across the Board", + "description": "Use all five star ratings at least once" + }, + "tough_crowd": { + "name": "Tough Crowd", + "description": "Give 10 books a rating of 2 stars or lower" + }, + "wordsmith": { + "name": "Wordsmith", + "description": "Write a note longer than 280 characters" + }, + "box_of_crayons": { + "name": "Box of Crayons", + "description": "Use all 5 highlight colors" + }, + "synced_up": { + "name": "Synced Up", + "description": "Sync reading progress from a Kobo or KOReader device for the first time" + }, + "full_orbit": { + "name": "Full Orbit", + "description": "Log reading from three different reading sources" + }, + "two_worlds": { + "name": "Two Worlds", + "description": "Read the same book from two or more different sources" + } + }, + "achievementCategories": { + "reading": "Reading", + "library": "Library", + "exploration": "Exploration", + "dedication": "Dedication", + "devices": "Devices" + }, + "achievementSecret": { + "name": "Secret Achievement", + "description": "Keep reading to reveal this achievement." + }, + "monthlyChallenge": { + "short-read": { + "title": "Quick Read", + "description": "Finish a book under 200 pages this month" + }, + "genre-explorer": { + "title": "Genre Explorer", + "description": "Read a book in a genre new to you this month" + }, + "finish-oldest": { + "title": "Clear the Backlog", + "description": "Finish your oldest in-progress book" + }, + "streak-builder": { + "title": "Streak Builder", + "description": "Read for 5 consecutive days this month" + }, + "new-author": { + "title": "New Voice", + "description": "Read a book by an author new to you this month" + }, + "page-milestone": { + "title": "Page Turner", + "description": "Read 500 pages this month" + } + } } } diff --git a/client/src/stores/locale.ts b/client/src/stores/locale.ts index 0ab8857b1..7670ea130 100644 --- a/client/src/stores/locale.ts +++ b/client/src/stores/locale.ts @@ -1,49 +1,13 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import { DEFAULT_LOCALE, SUPPORTED_LOCALES, isSupportedLocale, type Locale } from '@bookorbit/types' +import { DEFAULT_LOCALE, isSupportedLocale, matchSupportedLocale, type Locale } from '@bookorbit/types' import { storage } from '@/services/storage' import { activateI18nLocale, loadLocaleMessages } from '@/i18n' const STORAGE_KEY = 'locale' -function canonicalizeLocale(value: string): string | null { - try { - return Intl.getCanonicalLocales(value)[0] ?? null - } catch { - return null - } -} - -export function matchSupportedLocale(candidates: readonly string[]): Locale | null { - const supportedByCanonical = new Map() - const supportedByLanguageAndScript = new Map() - for (const locale of SUPPORTED_LOCALES) { - const canonical = canonicalizeLocale(locale) - if (!canonical) continue - supportedByCanonical.set(canonical.toLowerCase(), locale) - const maximized = new Intl.Locale(canonical).maximize() - if (maximized.script) { - supportedByLanguageAndScript.set(`${maximized.language.toLowerCase()}-${maximized.script.toLowerCase()}`, locale) - } - } - - for (const candidate of candidates) { - const canonical = canonicalizeLocale(candidate) - if (!canonical) continue - const exact = supportedByCanonical.get(canonical.toLowerCase()) - if (exact) return exact - const maximized = new Intl.Locale(canonical).maximize() - const language = maximized.language.toLowerCase() - if (maximized.script) { - const scriptMatch = supportedByLanguageAndScript.get(`${language}-${maximized.script.toLowerCase()}`) - if (scriptMatch) return scriptMatch - } - const baseLocale = supportedByCanonical.get(language) - if (baseLocale) return baseLocale - } - - return null -} +// The server negotiates `Accept-Language` with the same matcher, so it lives with the locale list. +export { matchSupportedLocale } export function detectInitialLocale(): Locale { const stored = storage.get(STORAGE_KEY, '') diff --git a/packages/types/src/locale.ts b/packages/types/src/locale.ts index b4bd8be2a..c56b58df5 100644 --- a/packages/types/src/locale.ts +++ b/packages/types/src/locale.ts @@ -93,3 +93,78 @@ export interface LocalePreferences { export function isSupportedLocale(value: unknown): value is Locale { return typeof value === "string" && (SUPPORTED_LOCALES as readonly string[]).includes(value); } + +function canonicalizeLocale(value: string): string | null { + try { + return Intl.getCanonicalLocales(value)[0] ?? null; + } catch { + return null; + } +} + +/** + * The supported locale that best serves a ranked list of language tags, or null when none does. + * An exact tag wins, then the same language and script (`zh-TW` and `zh-Hant-HK` both reach + * `zh-Hant`), then the bare language (`pt-BR` reaches `pt`). + */ +export function matchSupportedLocale(candidates: readonly string[]): Locale | null { + const supportedByCanonical = new Map(); + const supportedByLanguageAndScript = new Map(); + for (const locale of SUPPORTED_LOCALES) { + const canonical = canonicalizeLocale(locale); + if (!canonical) continue; + supportedByCanonical.set(canonical.toLowerCase(), locale); + const maximized = new Intl.Locale(canonical).maximize(); + if (maximized.script) { + supportedByLanguageAndScript.set(`${maximized.language.toLowerCase()}-${maximized.script.toLowerCase()}`, locale); + } + } + + for (const candidate of candidates) { + const canonical = canonicalizeLocale(candidate); + if (!canonical) continue; + const exact = supportedByCanonical.get(canonical.toLowerCase()); + if (exact) return exact; + const maximized = new Intl.Locale(canonical).maximize(); + const language = maximized.language.toLowerCase(); + if (maximized.script) { + const scriptMatch = supportedByLanguageAndScript.get(`${language}-${maximized.script.toLowerCase()}`); + if (scriptMatch) return scriptMatch; + } + const baseLocale = supportedByCanonical.get(language); + if (baseLocale) return baseLocale; + } + + return null; +} + +/** + * The language tags of an `Accept-Language` header, most preferred first. The `*` wildcard and + * tags weighted `q=0` are dropped; a weight that is not a number counts as 1, and equal weights + * keep the order they were sent in. + */ +export function parseAcceptLanguage(header: string | null | undefined): string[] { + if (!header) return []; + const entries: { tag: string; quality: number; index: number }[] = []; + header.split(",").forEach((part, index) => { + const [rawTag, ...parameters] = part.split(";"); + const tag = rawTag?.trim(); + if (!tag || tag === "*") return; + let quality = 1; + for (const parameter of parameters) { + const [name, value] = parameter.split("=").map((piece) => piece.trim()); + if (name?.toLowerCase() !== "q") continue; + const parsed = Number(value); + quality = Number.isFinite(parsed) ? parsed : 1; + } + if (quality <= 0) return; + entries.push({ tag, quality, index }); + }); + entries.sort((left, right) => right.quality - left.quality || left.index - right.index); + return entries.map((entry) => entry.tag); +} + +/** The supported locale an `Accept-Language` header asks for, or null when it names none. */ +export function localeFromAcceptLanguage(header: string | null | undefined): Locale | null { + return matchSupportedLocale(parseAcceptLanguage(header)); +} diff --git a/server/src/common/decorators/request-locale.decorator.ts b/server/src/common/decorators/request-locale.decorator.ts new file mode 100644 index 000000000..276f7aefb --- /dev/null +++ b/server/src/common/decorators/request-locale.decorator.ts @@ -0,0 +1,13 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { DEFAULT_LOCALE, localeFromAcceptLanguage, type Locale } from '@bookorbit/types'; + +/** + * The supported language the request's `Accept-Language` asks for, or English when it names none. + * Pair it with `@Header('Vary', 'Accept-Language')` on the route, so a shared cache never hands one + * language's response to another. + */ +export const RequestLocale = createParamDecorator((_: unknown, ctx: ExecutionContext): Locale => { + const request = ctx.switchToHttp().getRequest<{ headers: Record }>(); + const header = request.headers['accept-language']; + return localeFromAcceptLanguage(Array.isArray(header) ? header.join(',') : header) ?? DEFAULT_LOCALE; +}); diff --git a/server/src/common/i18n/server-text.test.ts b/server/src/common/i18n/server-text.test.ts new file mode 100644 index 000000000..4b2ceb555 --- /dev/null +++ b/server/src/common/i18n/server-text.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, afterEach } from 'vitest'; +import { ACHIEVEMENT_CATEGORY_LABELS, localeFromAcceptLanguage, parseAcceptLanguage, type ChallengeType } from '@bookorbit/types'; + +import { ACHIEVEMENT_SEED } from '../../modules/achievement/seed/achievement-seed'; +import { computeChallengeResult } from '../../modules/dashboard/dashboard-widget.calculations'; +import { englishCatalog, primeServerTextCatalog, resetServerTextCache, serverText } from './server-text'; + +const CHALLENGE_TYPES: ChallengeType[] = ['short-read', 'genre-explorer', 'finish-oldest', 'streak-builder', 'new-author', 'page-milestone']; + +afterEach(() => resetServerTextCache()); + +describe('serverText', () => { + it('returns the English it is given and never looks English up', () => { + primeServerTextCatalog('en', { achievements: { books_finished_1: { name: 'Something else' } } }); + + expect(serverText('en', 'achievements.books_finished_1.name', 'Ink Initiate')).toBe('Ink Initiate'); + }); + + it('returns a translation when the language has one', () => { + primeServerTextCatalog('de', { achievements: { books_finished_1: { name: 'Tinten-Neuling' } } }); + + expect(serverText('de', 'achievements.books_finished_1.name', 'Ink Initiate')).toBe('Tinten-Neuling'); + }); + + it('falls back to English for a missing, empty or non-string translation', () => { + primeServerTextCatalog('de', { achievements: { books_finished_1: { name: ' ' }, books_finished_2: 'not a node' } }); + + expect(serverText('de', 'achievements.books_finished_1.name', 'Ink Initiate')).toBe('Ink Initiate'); + expect(serverText('de', 'achievements.books_finished_2.name', 'Spine Scout')).toBe('Spine Scout'); + expect(serverText('de', 'achievements.books_finished_3.name', 'Story Stalwart')).toBe('Story Stalwart'); + }); +}); + +describe('the English source catalog', () => { + // Crowdin translates en.json, but English responses are built from the seed and the constants. + // If the two drift, translators would be working from text no one sees. + it('mirrors every seeded achievement', () => { + const achievements = englishCatalog().achievements as Record; + + expect(Object.keys(achievements).sort()).toEqual(ACHIEVEMENT_SEED.map((achievement) => achievement.key).sort()); + for (const achievement of ACHIEVEMENT_SEED) { + expect(achievements[achievement.key]).toEqual({ name: achievement.name, description: achievement.description }); + } + }); + + it('mirrors the category labels, the secret placeholder and the monthly challenges', () => { + const catalog = englishCatalog(); + + expect(catalog.achievementCategories).toEqual(ACHIEVEMENT_CATEGORY_LABELS); + expect(catalog.achievementSecret).toEqual({ name: 'Secret Achievement', description: 'Keep reading to reveal this achievement.' }); + const challenges = catalog.monthlyChallenge as Record; + expect(Object.keys(challenges).sort()).toEqual([...CHALLENGE_TYPES].sort()); + for (const type of CHALLENGE_TYPES) { + const result = computeChallengeResult( + type, + { + shortBooksCompleted: 0, + newGenresRead: 0, + oldestInProgressFinished: false, + maxStreakThisMonth: 0, + newAuthorsRead: 0, + pagesReadThisMonth: 0, + }, + 2026, + 1, + ); + expect(challenges[type]).toEqual({ title: result.title, description: result.description }); + } + }); +}); + +describe('Accept-Language', () => { + it('orders tags by weight and keeps the sent order for equal weights', () => { + expect(parseAcceptLanguage('fr;q=0.5, de-DE, de;q=0.9, nl')).toEqual(['de-DE', 'nl', 'de', 'fr']); + }); + + it('drops the wildcard and zero weights, and reads a broken weight as 1', () => { + expect(parseAcceptLanguage('*, fr;q=0, it;q=abc')).toEqual(['it']); + expect(parseAcceptLanguage('')).toEqual([]); + expect(parseAcceptLanguage(undefined)).toEqual([]); + }); + + it('resolves what the iOS app and browsers send to a supported locale', () => { + expect(localeFromAcceptLanguage('de-DE,de;q=0.9')).toBe('de'); + expect(localeFromAcceptLanguage('zh-Hans')).toBe('zh'); + expect(localeFromAcceptLanguage('zh-Hant')).toBe('zh-Hant'); + expect(localeFromAcceptLanguage('zh-TW')).toBe('zh-Hant'); + expect(localeFromAcceptLanguage('pt-BR')).toBe('pt'); + expect(localeFromAcceptLanguage('ca-ES, es;q=0.8')).toBe('es'); + expect(localeFromAcceptLanguage('en-US,en;q=0.9')).toBe('en'); + }); + + it('names no locale for an unsupported or malformed header', () => { + expect(localeFromAcceptLanguage('xx-YY')).toBeNull(); + expect(localeFromAcceptLanguage('*')).toBeNull(); + expect(localeFromAcceptLanguage('not a tag!!')).toBeNull(); + expect(localeFromAcceptLanguage(null)).toBeNull(); + }); +}); diff --git a/server/src/common/i18n/server-text.ts b/server/src/common/i18n/server-text.ts new file mode 100644 index 000000000..c350fcfb8 --- /dev/null +++ b/server/src/common/i18n/server-text.ts @@ -0,0 +1,83 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { DEFAULT_LOCALE, type Locale } from '@bookorbit/types'; + +/** + * Text the server writes itself and sends to clients that show it as is: achievement names and + * descriptions, category labels, the monthly challenge. Clients ask for a language with + * `Accept-Language`; the web sends the one picked in its settings and the iOS app the one its own + * interface resolved to. + * + * The translations live in the web's catalogs, under `serverText`, so they reach Crowdin, its + * validation and its translators through the one pipeline the web already has. + * + * English is never looked up. It comes from the same place it always did (the seeded rows, the + * constants), so a request without the header, or from a client that predates it, gets exactly the + * bytes it got before. `serverText` in `en.json` mirrors that text only so Crowdin has a source to + * translate, and a test keeps the two equal. A translation that is missing or empty falls back to + * English. + */ +type Catalog = { [key: string]: string | Catalog }; + +const NAMESPACE = 'serverText'; + +const catalogs = new Map(); + +/** + * Where the web's catalogs are. In the repository they sit beside the server, whether this runs + * from `src` or `dist`; the image copies them next to `dist`. Found on first use rather than at + * import, so a module that mocks `fs` can still import anything that imports this. + */ +function localesDirectory(): string | undefined { + const candidates = [join(__dirname, '..', '..', '..', '..', 'client', 'src', 'locales'), join(__dirname, '..', '..', '..', 'locales')]; + return candidates.find((directory) => existsSync(join(directory, `${DEFAULT_LOCALE}.json`))); +} + +function catalog(locale: Locale): Catalog { + const cached = catalogs.get(locale); + if (cached) return cached; + const directory = localesDirectory(); + const file = directory ? join(directory, `${locale}.json`) : null; + let loaded: Catalog = {}; + if (file && existsSync(file)) { + try { + const messages = JSON.parse(readFileSync(file, 'utf8')) as Catalog; + const namespace = messages[NAMESPACE]; + loaded = namespace && typeof namespace === 'object' ? namespace : {}; + } catch { + loaded = {}; + } + } + catalogs.set(locale, loaded); + return loaded; +} + +function lookup(tree: Catalog, key: string): string | null { + let node: string | Catalog | undefined = tree; + for (const part of key.split('.')) { + if (!node || typeof node === 'string') return null; + node = node[part]; + } + return typeof node === 'string' && node.trim().length > 0 ? node : null; +} + +/** `english` in English or when `locale` has no translation for `key`, otherwise the translation. */ +export function serverText(locale: Locale, key: string, english: string): string { + if (locale === DEFAULT_LOCALE) return english; + return lookup(catalog(locale), key) ?? english; +} + +/** The English source of `serverText`, for the test that keeps it equal to the text it mirrors. */ +export function englishCatalog(): Catalog { + return catalog(DEFAULT_LOCALE); +} + +/** Stands in a `serverText` tree for `locale` until `resetServerTextCache`, for tests. */ +export function primeServerTextCatalog(locale: Locale, tree: Catalog): void { + catalogs.set(locale, tree); +} + +/** Forgets loaded and primed catalogs. */ +export function resetServerTextCache(): void { + catalogs.clear(); +} diff --git a/server/src/modules/achievement/achievement.controller.ts b/server/src/modules/achievement/achievement.controller.ts index 4d2756c22..040493939 100644 --- a/server/src/modules/achievement/achievement.controller.ts +++ b/server/src/modules/achievement/achievement.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post, UseGuards } from '@nestjs/common'; -import type { AchievementCatalogueResponse, AchievementCelebrationClaim, AchievementHintsResponse } from '@bookorbit/types'; +import { Controller, Get, Header, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post, UseGuards } from '@nestjs/common'; +import type { AchievementCatalogueResponse, AchievementCelebrationClaim, AchievementHintsResponse, Locale } from '@bookorbit/types'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { RequestLocale } from '../../common/decorators/request-locale.decorator'; import type { RequestUser } from '../../common/types/request-user'; import { AchievementService } from './achievement.service'; import { AchievementBackfillService, type BackfillResult } from './achievement-backfill.service'; @@ -18,8 +19,9 @@ export class AchievementController { ) {} @Get() - async getCatalogue(@CurrentUser() user: RequestUser): Promise { - return this.service.getCatalogue(user); + @Header('Vary', 'Accept-Language') + async getCatalogue(@CurrentUser() user: RequestUser, @RequestLocale() locale: Locale): Promise { + return this.service.getCatalogue(user, locale); } @Get('hints') @@ -28,8 +30,9 @@ export class AchievementController { } @Post('celebrations/claim') - async claimCelebration(@CurrentUser() user: RequestUser): Promise { - return this.service.claimCelebration(user); + @Header('Vary', 'Accept-Language') + async claimCelebration(@CurrentUser() user: RequestUser, @RequestLocale() locale: Locale): Promise { + return this.service.claimCelebration(user, locale); } @Post('celebrations/:claimId/acknowledge') diff --git a/server/src/modules/achievement/achievement.service.test.ts b/server/src/modules/achievement/achievement.service.test.ts index d0ee3697b..a5291904c 100644 --- a/server/src/modules/achievement/achievement.service.test.ts +++ b/server/src/modules/achievement/achievement.service.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { primeServerTextCatalog, resetServerTextCache } from '../../common/i18n/server-text'; import { AchievementService } from './achievement.service'; import { AchievementEventsService, ACHIEVEMENT_EVENT_BACKFILL, ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED } from './achievement-events.service'; @@ -207,6 +208,58 @@ describe('AchievementService', () => { expect(item?.threshold).toBeNull(); expect(item?.context).toBeNull(); }); + + describe('in another language', () => { + const visible = { + key: 'books_finished_1', + groupKey: 'books_finished', + tier: 1, + category: 'reading', + name: 'Ink Initiate', + description: 'Finish your first book', + iconName: 'book-open', + rarity: 'common', + threshold: 1, + hidden: false, + sortOrder: 1, + }; + const secret = { ...visible, key: 'secret_1', groupKey: null, tier: null, name: 'Secret', description: 'Hidden thing', hidden: true }; + + beforeEach(() => { + primeServerTextCatalog('de', { + achievements: { books_finished_1: { name: 'Tinten-Neuling', description: 'Beende dein erstes Buch' } }, + achievementCategories: { reading: 'Lesen' }, + achievementSecret: { name: 'Geheimer Erfolg', description: 'Lies weiter, um ihn zu enthüllen.' }, + }); + repo.findUserAchievements.mockResolvedValue([]); + }); + + afterEach(() => resetServerTextCache()); + + it('translates names, descriptions, category labels and the secret placeholder', async () => { + repo.findAllAchievements.mockResolvedValue([visible, secret]); + + const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never, 'de'); + const reading = result.categories.find((c) => c.key === 'reading'); + + expect(reading?.label).toBe('Lesen'); + expect(reading?.achievements.map((item) => [item.name, item.description])).toEqual([ + ['Tinten-Neuling', 'Beende dein erstes Buch'], + ['Geheimer Erfolg', 'Lies weiter, um ihn zu enthüllen.'], + ]); + expect(result.categories.find((c) => c.key === 'library')?.label).toBe('Library'); + }); + + it('keeps the stored English when a request names no language', async () => { + repo.findAllAchievements.mockResolvedValue([visible]); + + const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never); + const reading = result.categories.find((c) => c.key === 'reading'); + + expect(reading?.label).toBe('Reading'); + expect(reading?.achievements[0]?.name).toBe('Ink Initiate'); + }); + }); }); describe('celebrations', () => { diff --git a/server/src/modules/achievement/achievement.service.ts b/server/src/modules/achievement/achievement.service.ts index 005768448..685878a1a 100644 --- a/server/src/modules/achievement/achievement.service.ts +++ b/server/src/modules/achievement/achievement.service.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import { Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; -import { NotificationType, ACHIEVEMENT_CATEGORY_LABELS } from '@bookorbit/types'; +import { DEFAULT_LOCALE, NotificationType, ACHIEVEMENT_CATEGORY_LABELS, type Locale } from '@bookorbit/types'; import type { AchievementCatalogueResponse, AchievementCategoryGroup, @@ -8,6 +8,7 @@ import type { AchievementCategory, AchievementCelebrationClaim, } from '@bookorbit/types'; +import { serverText } from '../../common/i18n/server-text'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; import { getYearInTimeZone, resolveTimeZone, toDateKeyInTimeZone } from '../../common/utils/timezone.utils'; import type { RequestUser } from '../../common/types/request-user'; @@ -61,7 +62,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy { return this.seedPromise; } - async getCatalogue(user: RequestUser): Promise { + async getCatalogue(user: RequestUser, locale: Locale = DEFAULT_LOCALE): Promise { const userId = user.id; const [allAchievements, userAchievements] = await Promise.all([this.repo.findAllAchievements(), this.repo.findUserAchievements(userId)]); @@ -87,7 +88,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy { const items = grouped.get(category); if (!items) continue; - items.push(this.toItem(achievement, earned, progressMap.get(achievement.key) ?? null, accessibleBookIds)); + items.push(this.toItem(achievement, earned, progressMap.get(achievement.key) ?? null, accessibleBookIds, locale)); } let totalEarned = 0; @@ -102,7 +103,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy { categories.push({ key: cat, - label: ACHIEVEMENT_CATEGORY_LABELS[cat], + label: serverText(locale, `achievementCategories.${cat}`, ACHIEVEMENT_CATEGORY_LABELS[cat]), earnedCount, totalCount: items.length, achievements: items, @@ -112,7 +113,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy { return { categories, totalEarned, totalAvailable }; } - async claimCelebration(user: RequestUser): Promise { + async claimCelebration(user: RequestUser, locale: Locale = DEFAULT_LOCALE): Promise { if (!(await this.userService.isAchievementEnabled(user.id))) return null; const claimedAt = new Date(); @@ -125,7 +126,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy { return { claimId, expiresAt: expiresAt.toISOString(), - achievement: this.toItem(claimed.achievement, claimed.award, null, accessibleBookIds), + achievement: this.toItem(claimed.achievement, claimed.award, null, accessibleBookIds, locale), }; } @@ -202,6 +203,7 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy { earned: UserAchievementRow | undefined, currentProgress: number | null, accessibleBookIds: Set, + locale: Locale, ): AchievementItem { const lockedSecret = achievement.hidden && !earned; const context = earned?.contextJson && typeof earned.contextJson === 'object' ? (earned.contextJson as Record) : null; @@ -214,8 +216,12 @@ export class AchievementService implements OnModuleInit, OnModuleDestroy { groupKey: lockedSecret ? null : achievement.groupKey, tier: lockedSecret ? null : achievement.tier, category: achievement.category as AchievementCategory, - name: lockedSecret ? 'Secret Achievement' : achievement.name, - description: lockedSecret ? 'Keep reading to reveal this achievement.' : achievement.description, + name: lockedSecret + ? serverText(locale, 'achievementSecret.name', 'Secret Achievement') + : serverText(locale, `achievements.${achievement.key}.name`, achievement.name), + description: lockedSecret + ? serverText(locale, 'achievementSecret.description', 'Keep reading to reveal this achievement.') + : serverText(locale, `achievements.${achievement.key}.description`, achievement.description), iconName: lockedSecret ? 'lock' : achievement.iconName, rarity: lockedSecret ? 'common' : (achievement.rarity as AchievementItem['rarity']), threshold: lockedSecret ? null : achievement.threshold, diff --git a/server/src/modules/dashboard/dashboard-widget.service.test.ts b/server/src/modules/dashboard/dashboard-widget.service.test.ts index f19c0f07f..22bb8221b 100644 --- a/server/src/modules/dashboard/dashboard-widget.service.test.ts +++ b/server/src/modules/dashboard/dashboard-widget.service.test.ts @@ -1,6 +1,7 @@ import type { CurrentlyReadingWidgetData, LibraryOverviewWidgetData, NeglectedGemsWidgetData, ReadingStreakWidgetData } from '@bookorbit/types'; import type { RequestUser } from '../../common/types/request-user'; +import { primeServerTextCatalog, resetServerTextCache } from '../../common/i18n/server-text'; import { pickAnnotationIndex } from './dashboard-widget.calculations'; import { DashboardWidgetService } from './dashboard-widget.service'; import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types'; @@ -335,6 +336,41 @@ describe('DashboardWidgetService', () => { expect(typeof result.completed).toBe('boolean'); }); + it('translates the title and description on the way out and leaves the cached English alone', async () => { + const { service, widgetRepo, libraryService } = makeService(); + libraryService.findAccessibleLibraryIds.mockResolvedValue([1]); + widgetRepo.getChallengePatternData.mockResolvedValue({ + avgPageCount: 100, + uniqueGenresLast6Months: 10, + staleInProgressCount: 0, + currentStreak: 10, + maxStreakThisMonth: 10, + topAuthorBookCount: 1, + totalBooksRead: 20, + pagesThisMonth: 200, + shortBooksCompleted: 0, + newGenresRead: 0, + oldestInProgressFinished: false, + newAuthorsRead: 0, + pagesReadThisMonth: 200, + }); + primeServerTextCatalog('de', { + monthlyChallenge: { 'page-milestone': { title: 'Seitenwender', description: 'Lies diesen Monat 500 Seiten' } }, + }); + + try { + const german = await service.getMonthlyChallenge(makeUser(), 'de'); + const english = await service.getMonthlyChallenge(makeUser()); + + expect(german.challengeType).toBe('page-milestone'); + expect([german.title, german.description]).toEqual(['Seitenwender', 'Lies diesen Monat 500 Seiten']); + expect([english.title, english.description]).toEqual(['Page Turner', 'Read 500 pages this month']); + expect(widgetRepo.getChallengePatternData).toHaveBeenCalledTimes(1); + } finally { + resetServerTextCache(); + } + }); + it('marks finish-oldest challenge complete when oldestInProgressFinished is true', async () => { const { service, widgetRepo, libraryService } = makeService(); libraryService.findAccessibleLibraryIds.mockResolvedValue([1]); diff --git a/server/src/modules/dashboard/dashboard-widget.service.ts b/server/src/modules/dashboard/dashboard-widget.service.ts index 49539107d..ab8842c4a 100644 --- a/server/src/modules/dashboard/dashboard-widget.service.ts +++ b/server/src/modules/dashboard/dashboard-widget.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { DEFAULT_LOCALE, type Locale } from '@bookorbit/types'; import type { CurrentlyReadingWidgetData, @@ -20,6 +21,7 @@ import type { YearProjectionWidgetData, } from '@bookorbit/types'; +import { serverText } from '../../common/i18n/server-text'; import type { RequestUser } from '../../common/types/request-user'; import { StatsCache } from '../../common/cache/stats-cache'; import { mapWithConcurrency } from '../../common/utils/batch.utils'; @@ -261,44 +263,55 @@ export class DashboardWidgetService implements OnModuleInit, OnModuleDestroy { }); } - async getMonthlyChallenge(user: RequestUser): Promise { + async getMonthlyChallenge(user: RequestUser, locale: Locale = DEFAULT_LOCALE): Promise { const accessibleLibraryIds = await this.getLibraryIds(user); const clock = this.readerClock(user); this.readingAttemptEvents.flushPendingChanges(user.id); - return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), this.calendarCacheKey('monthly-challenge', clock), async () => { - const contentFilters = this.getContentFilters(user); - const { year, month } = clock; - const monthStartDay = `${clock.today.slice(0, 7)}-01`; - const monthStart = toTimeZoneStartOfDay(monthStartDay, clock.timeZone); - const sixMonthsAgo = new Date(); - sixMonthsAgo.setUTCMonth(sixMonthsAgo.getUTCMonth() - 6); + // The cache holds the English result and the language is applied on the way out, so one entry + // serves every language a user's clients ask in. + const challenge = await this.staleCache.get( + this.cacheOwnerKey(user, accessibleLibraryIds), + this.calendarCacheKey('monthly-challenge', clock), + async () => { + const contentFilters = this.getContentFilters(user); + const { year, month } = clock; + const monthStartDay = `${clock.today.slice(0, 7)}-01`; + const monthStart = toTimeZoneStartOfDay(monthStartDay, clock.timeZone); + const sixMonthsAgo = new Date(); + sixMonthsAgo.setUTCMonth(sixMonthsAgo.getUTCMonth() - 6); - const data = await this.widgetRepo.getChallengePatternData( - user.id, - accessibleLibraryIds, - { start: monthStart, startDay: monthStartDay, today: clock.today }, - sixMonthsAgo, - contentFilters, - ); - const eligible = findEligibleChallenges(data); - const challengeType = selectChallenge(eligible, user.id, year, month); + const data = await this.widgetRepo.getChallengePatternData( + user.id, + accessibleLibraryIds, + { start: monthStart, startDay: monthStartDay, today: clock.today }, + sixMonthsAgo, + contentFilters, + ); + const eligible = findEligibleChallenges(data); + const challengeType = selectChallenge(eligible, user.id, year, month); - const result = computeChallengeResult( - challengeType, - { - shortBooksCompleted: data.shortBooksCompleted, - newGenresRead: data.newGenresRead, - oldestInProgressFinished: data.oldestInProgressFinished, - maxStreakThisMonth: data.maxStreakThisMonth, - newAuthorsRead: data.newAuthorsRead, - pagesReadThisMonth: data.pagesReadThisMonth, - }, - year, - month, - ); + const result = computeChallengeResult( + challengeType, + { + shortBooksCompleted: data.shortBooksCompleted, + newGenresRead: data.newGenresRead, + oldestInProgressFinished: data.oldestInProgressFinished, + maxStreakThisMonth: data.maxStreakThisMonth, + newAuthorsRead: data.newAuthorsRead, + pagesReadThisMonth: data.pagesReadThisMonth, + }, + year, + month, + ); - return { challengeType, ...result }; - }); + return { challengeType, ...result }; + }, + ); + return { + ...challenge, + title: serverText(locale, `monthlyChallenge.${challenge.challengeType}.title`, challenge.title), + description: serverText(locale, `monthlyChallenge.${challenge.challengeType}.description`, challenge.description), + }; } async getYearProjection(user: RequestUser): Promise { @@ -398,13 +411,13 @@ export class DashboardWidgetService implements OnModuleInit, OnModuleDestroy { }); } - private readonly widgetLoaders: Record Promise> = { + private readonly widgetLoaders: Record Promise> = { 'reading-goal': (user) => this.getReadingGoal(user), 'currently-reading': (user) => this.getCurrentlyReading(user), 'reading-streak': (user) => this.getReadingStreak(user), 'library-overview': (user) => this.getLibraryOverview(user), 'highlight-of-the-day': (user) => this.getHighlightOfTheDay(user), - 'monthly-challenge': (user) => this.getMonthlyChallenge(user), + 'monthly-challenge': (user, locale) => this.getMonthlyChallenge(user, locale), 'year-projection': (user) => this.getYearProjection(user), 'neglected-gems': (user) => this.getNeglectedGems(user), 'reading-dna': (user) => this.getReadingDna(user), @@ -421,14 +434,14 @@ export class DashboardWidgetService implements OnModuleInit, OnModuleDestroy { * retry, so a request lost in that crowd leaves a tile stuck on "Failed to load" for the life of * the page. Failures are reported per widget rather than failing the batch. */ - async getWidgets(types: readonly WidgetType[], user: RequestUser): Promise { + async getWidgets(types: readonly WidgetType[], user: RequestUser, locale: Locale = DEFAULT_LOCALE): Promise { const startedAt = Date.now(); this.logger.debug(`[dashboard.widget_batch] [start] userId=${user.id} widgetCount=${types.length} - widget batch started`); const items = await mapWithConcurrency(types, WIDGET_QUERY_CONCURRENCY, async (type): Promise => { const widgetStartedAt = Date.now(); try { - return { type, data: await this.widgetLoaders[type](user), failed: false }; + return { type, data: await this.widgetLoaders[type](user, locale), failed: false }; } catch (error) { const errorClass = error instanceof Error ? error.constructor.name : typeof error; const message = sanitizeLogValue(error instanceof Error ? error.message : error); diff --git a/server/src/modules/dashboard/dashboard.controller.test.ts b/server/src/modules/dashboard/dashboard.controller.test.ts index 696041754..ad7c09fa3 100644 --- a/server/src/modules/dashboard/dashboard.controller.test.ts +++ b/server/src/modules/dashboard/dashboard.controller.test.ts @@ -88,9 +88,9 @@ describe('DashboardController', () => { const mockResult = { items: [{ type: 'reading-goal' as const, data: null, failed: false }] }; widgetService.getWidgets.mockResolvedValue(mockResult); - const result = await controller.getWidgets(user, { widgets: ['reading-goal', 'reading-dna'] }); + const result = await controller.getWidgets(user, { widgets: ['reading-goal', 'reading-dna'] }, 'de'); - expect(widgetService.getWidgets).toHaveBeenCalledWith(['reading-goal', 'reading-dna'], user); + expect(widgetService.getWidgets).toHaveBeenCalledWith(['reading-goal', 'reading-dna'], user, 'de'); expect(result).toBe(mockResult); }); @@ -99,9 +99,9 @@ describe('DashboardController', () => { const user = makeUser(); widgetService.getWidgets.mockResolvedValue({ items: [] }); - await controller.getWidgets(user, { widgets: ['reading-goal', 'reading-goal', 'reading-dna'] }); + await controller.getWidgets(user, { widgets: ['reading-goal', 'reading-goal', 'reading-dna'] }, 'en'); - expect(widgetService.getWidgets).toHaveBeenCalledWith(['reading-goal', 'reading-dna'], user); + expect(widgetService.getWidgets).toHaveBeenCalledWith(['reading-goal', 'reading-dna'], user, 'en'); }); it('refresh clears cached widgets for the current user', () => { @@ -171,10 +171,21 @@ describe('DashboardController', () => { expect(result).toEqual(mockResult); }); + it('getMonthlyChallenge passes the request language to widgetService', async () => { + const { controller, widgetService } = makeController(); + const user = makeUser(); + const mockResult = { challengeType: 'page-milestone', progress: 0, target: 500 }; + widgetService.getMonthlyChallenge.mockResolvedValue(mockResult); + + const result = await controller.getMonthlyChallenge(user, 'fr'); + + expect(widgetService.getMonthlyChallenge).toHaveBeenCalledWith(user, 'fr'); + expect(result).toEqual(mockResult); + }); + it.each([ ['getHighlightOfTheDay', { text: 'quote', bookId: 1 }], ['getHighlights', [{ text: 'quote', bookId: 1 }]], - ['getMonthlyChallenge', { challengeType: 'page-milestone', progress: 0, target: 500 }], ['getYearProjection', { projectedBooks: 30, daysRemaining: 200 }], ['getNeglectedGems', { gems: [] }], ['getReadingDna', { archetype: 'Reader', booksAnalyzed: 10 }], diff --git a/server/src/modules/dashboard/dashboard.controller.ts b/server/src/modules/dashboard/dashboard.controller.ts index 56b0e4fe6..7ba728ebd 100644 --- a/server/src/modules/dashboard/dashboard.controller.ts +++ b/server/src/modules/dashboard/dashboard.controller.ts @@ -1,6 +1,21 @@ -import { Body, Controller, DefaultValuePipe, Get, HttpCode, HttpStatus, Param, ParseEnumPipe, ParseIntPipe, Post, Query } from '@nestjs/common'; +import { + Body, + Controller, + DefaultValuePipe, + Get, + Header, + HttpCode, + HttpStatus, + Param, + ParseEnumPipe, + ParseIntPipe, + Post, + Query, +} from '@nestjs/common'; +import type { Locale } from '@bookorbit/types'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { RequestLocale } from '../../common/decorators/request-locale.decorator'; import type { RequestUser } from '../../common/types/request-user'; import { DashboardService } from './dashboard.service'; import { DashboardWidgetService } from './dashboard-widget.service'; @@ -33,8 +48,9 @@ export class DashboardController { @Post('widgets/batch') @HttpCode(HttpStatus.OK) - getWidgets(@CurrentUser() user: RequestUser, @Body() dto: DashboardWidgetBatchDto) { - return this.widgetService.getWidgets([...new Set(dto.widgets)], user); + @Header('Vary', 'Accept-Language') + getWidgets(@CurrentUser() user: RequestUser, @Body() dto: DashboardWidgetBatchDto, @RequestLocale() locale: Locale) { + return this.widgetService.getWidgets([...new Set(dto.widgets)], user, locale); } @Post('refresh') @@ -74,8 +90,9 @@ export class DashboardController { } @Get('widgets/monthly-challenge') - getMonthlyChallenge(@CurrentUser() user: RequestUser) { - return this.widgetService.getMonthlyChallenge(user); + @Header('Vary', 'Accept-Language') + getMonthlyChallenge(@CurrentUser() user: RequestUser, @RequestLocale() locale: Locale) { + return this.widgetService.getMonthlyChallenge(user, locale); } @Get('widgets/year-projection')