diff --git a/src/background/fetchJson.ts b/src/background/fetchJson.ts new file mode 100644 index 0000000..2d47bcc --- /dev/null +++ b/src/background/fetchJson.ts @@ -0,0 +1,21 @@ +/** Fetch JSON with abort timeout; non-OK / network / parse failures return null. */ + +export async function fetchJsonWithTimeout( + url: string, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + signal: controller.signal, + headers: { Accept: 'application/json' }, + }); + if (!res.ok) return null; + return (await res.json()) as T; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} diff --git a/src/background/googleBooks.ts b/src/background/googleBooks.ts index 948793d..f453c82 100644 --- a/src/background/googleBooks.ts +++ b/src/background/googleBooks.ts @@ -13,12 +13,15 @@ import type { import { isValidIsbn, normalizeIsbn, toIsbn13 } from '@/shared/isbn'; import type { BookSearchResult } from './openLibrary'; import { getPreferences } from './storage'; +import { BoundedTtlCache } from './ttlCache'; +import { fetchJsonWithTimeout } from './fetchJson'; const GB_BASE = 'https://www.googleapis.com/books/v1/volumes'; const REQUEST_TIMEOUT_MS = 12_000; const CACHE_TTL_MS = 1000 * 60 * 60 * 6; // 6 hours -const CACHE = new Map(); +export const MAX_CACHE_SIZE = 500; +const cache = new BoundedTtlCache(MAX_CACHE_SIZE, CACHE_TTL_MS); let googleBooksApiKey: string | null = null; @@ -97,36 +100,20 @@ interface GbVolumesResponse { // ─── Cache ─────────────────────────────────────────────────────────── function cacheGet(key: string): T | undefined { - const entry = CACHE.get(key); - if (!entry) return undefined; - if (Date.now() - entry.timestamp > CACHE_TTL_MS) { - CACHE.delete(key); - return undefined; - } - return entry.data as T; + return cache.get(key) as T | undefined; } -export const MAX_CACHE_SIZE = 500; - function cacheSet(key: string, data: unknown): void { - if (CACHE.has(key)) { - CACHE.delete(key); - } else if (CACHE.size >= MAX_CACHE_SIZE) { - const oldestKey = CACHE.keys().next().value; - if (oldestKey !== undefined) { - CACHE.delete(oldestKey); - } - } - CACHE.set(key, { data, timestamp: Date.now() }); + cache.set(key, data); } /** Test helper — clears in-memory cache. */ export function clearGoogleBooksCache(): void { - CACHE.clear(); + cache.clear(); } export function getGoogleBooksCacheSizeForTesting(): number { - return CACHE.size; + return cache.size; } export function setGoogleBooksCacheEntryForTesting(key: string, data: unknown): void { @@ -159,23 +146,7 @@ function volumeSourceUrl(volumeId: string): string { // ─── Fetch with timeout ────────────────────────────────────────────── async function fetchJson(url: string): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - try { - const res = await fetch(url, { - signal: controller.signal, - headers: { Accept: 'application/json' }, - }); - // Unauthenticated quota / forbidden — fail gracefully - if (res.status === 403 || res.status === 401) return null; - if (res.status === 404) return null; - if (!res.ok) return null; - return (await res.json()) as T; - } catch { - return null; - } finally { - clearTimeout(timer); - } + return fetchJsonWithTimeout(url, REQUEST_TIMEOUT_MS); } function buildVolumesUrl(params: Record): string { diff --git a/src/background/handlers/library.ts b/src/background/handlers/library.ts index b01b7a5..d627691 100644 --- a/src/background/handlers/library.ts +++ b/src/background/handlers/library.ts @@ -11,6 +11,8 @@ import { CheckLibraryStatusRequest, CheckLibraryStatusResponse, LibraryItem, + BroadcastMessage, + RatingHistoryEntry, } from '@/shared/types'; import { v4 as uuidv4 } from 'uuid'; import type { Reflection } from '@/shared/catalogTypes'; @@ -37,7 +39,20 @@ import { isSafeNavMediaId } from '@/shared/mediaIds'; import { invalidateProfileCache } from '../context'; import { mergeMediaItems } from '../mediaMerge'; import { broadcastMessage, parseSetUserNotesRequest, parseUpdateStatusRequest } from './utils'; -import type { RatingHistoryEntry } from '@/shared/types'; + +async function notifyLibraryUpdated( + action: Extract['action'], + mediaId: string, + libraryItem?: LibraryItem, +): Promise { + invalidateProfileCache(); + await broadcastMessage({ + type: 'LIBRARY_UPDATED', + action, + mediaId, + ...(libraryItem ? { libraryItem } : {}), + }); +} /** Max entries kept on `LibraryItem.ratingHistory`. */ export const RATING_HISTORY_CAP = 50; @@ -132,13 +147,7 @@ export const libraryHandlers: MessageHandlerMap = { await onNewLibraryItemCreated(); } - invalidateProfileCache(); - await broadcastMessage({ - type: 'LIBRARY_UPDATED', - action: 'add', - mediaId: libraryItem.mediaId, - libraryItem, - }); + await notifyLibraryUpdated('add', libraryItem.mediaId, libraryItem); return libraryItem; }, @@ -156,13 +165,7 @@ export const libraryHandlers: MessageHandlerMap = { existing.sanctuaryIntent = intentForStatus(req.status); existing.updatedAt = Date.now(); await putLibraryItem(existing); - invalidateProfileCache(); - await broadcastMessage({ - type: 'LIBRARY_UPDATED', - action: 'update', - mediaId: existing.mediaId, - libraryItem: existing, - }); + await notifyLibraryUpdated('update', existing.mediaId, existing); return { updated: true }; }, @@ -181,13 +184,7 @@ export const libraryHandlers: MessageHandlerMap = { existing.userRating = req.rating; existing.updatedAt = Date.now(); await putLibraryItem(existing); - invalidateProfileCache(); - await broadcastMessage({ - type: 'LIBRARY_UPDATED', - action: 'update', - mediaId: existing.mediaId, - libraryItem: existing, - }); + await notifyLibraryUpdated('update', existing.mediaId, existing); return { updated: true }; }, @@ -201,13 +198,7 @@ export const libraryHandlers: MessageHandlerMap = { existing.userTags = req.tags; existing.updatedAt = Date.now(); await putLibraryItem(existing); - invalidateProfileCache(); - await broadcastMessage({ - type: 'LIBRARY_UPDATED', - action: 'update', - mediaId: existing.mediaId, - libraryItem: existing, - }); + await notifyLibraryUpdated('update', existing.mediaId, existing); return { updated: true }; }, @@ -246,13 +237,7 @@ export const libraryHandlers: MessageHandlerMap = { logger.warn('[Subsume] SET_USER_NOTES reflection append failed:', err); } - invalidateProfileCache(); - await broadcastMessage({ - type: 'LIBRARY_UPDATED', - action: 'update', - mediaId: existing.mediaId, - libraryItem: existing, - }); + await notifyLibraryUpdated('update', existing.mediaId, existing); return { updated: true }; }, @@ -263,12 +248,7 @@ export const libraryHandlers: MessageHandlerMap = { throw new Error('Invalid mediaId'); } await removeLibraryItem(req.mediaId); - invalidateProfileCache(); - await broadcastMessage({ - type: 'LIBRARY_UPDATED', - action: 'remove', - mediaId: req.mediaId, - }); + await notifyLibraryUpdated('remove', req.mediaId); return { removed: true }; }, diff --git a/src/background/omdb.ts b/src/background/omdb.ts index 624bbc0..854405a 100644 --- a/src/background/omdb.ts +++ b/src/background/omdb.ts @@ -1,34 +1,23 @@ import { MediaRating } from '@/shared/types'; import { getPreferences } from './storage'; +import { BoundedTtlCache } from './ttlCache'; const BASE_URL = 'https://www.omdbapi.com/'; -const CACHE = new Map(); const CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours export const MAX_CACHE_SIZE = 500; - -function setCacheEntry(key: string, data: MediaRating[]): void { - if (CACHE.has(key)) { - CACHE.delete(key); - } else if (CACHE.size >= MAX_CACHE_SIZE) { - const oldestKey = CACHE.keys().next().value; - if (oldestKey !== undefined) { - CACHE.delete(oldestKey); - } - } - CACHE.set(key, { data, timestamp: Date.now() }); -} +const cache = new BoundedTtlCache(MAX_CACHE_SIZE, CACHE_TTL); export function clearOmdbCache(): void { - CACHE.clear(); + cache.clear(); } export function getOmdbCacheSizeForTesting(): number { - return CACHE.size; + return cache.size; } export function setOmdbCacheEntryForTesting(key: string, data: MediaRating[]): void { - setCacheEntry(key, data); + cache.set(key, data); } let omdbApiKey: string | null = null; @@ -92,9 +81,9 @@ export async function fetchOmdbRatings( } const cacheKey = `omdb_${title}_${year}_${type}`; - const cached = CACHE.get(cacheKey); - if (cached && Date.now() - cached.timestamp < CACHE_TTL) { - return cached.data; + const cached = cache.get(cacheKey); + if (cached !== undefined) { + return cached; } const omdbType = type === 'tv' ? 'series' : 'movie'; @@ -112,7 +101,7 @@ export async function fetchOmdbRatings( const isTransientError = error.includes('Invalid API key') || error.toLowerCase().includes('limit'); if (!isTransientError) { - setCacheEntry(cacheKey, []); + cache.set(cacheKey, []); } return []; } @@ -137,7 +126,7 @@ export async function fetchOmdbRatings( } } - setCacheEntry(cacheKey, ratings); + cache.set(cacheKey, ratings); return ratings; } catch (err) { console.error('[Subsume OMDb] Fetch failed', err); diff --git a/src/background/openLibrary.ts b/src/background/openLibrary.ts index 5130ce6..bd560fe 100644 --- a/src/background/openLibrary.ts +++ b/src/background/openLibrary.ts @@ -14,13 +14,16 @@ import type { SourceProvenance, } from '@/shared/catalogTypes'; import { isValidIsbn, normalizeIsbn, toIsbn13 } from '@/shared/isbn'; +import { BoundedTtlCache } from './ttlCache'; +import { fetchJsonWithTimeout } from './fetchJson'; const OL_BASE = 'https://openlibrary.org'; const COVERS_BASE = 'https://covers.openlibrary.org'; const REQUEST_TIMEOUT_MS = 12_000; const CACHE_TTL_MS = 1000 * 60 * 60 * 6; // 6 hours -const CACHE = new Map(); +export const MAX_CACHE_SIZE = 500; +const cache = new BoundedTtlCache(MAX_CACHE_SIZE, CACHE_TTL_MS); export interface BookSearchQuery { query: string; @@ -147,22 +150,16 @@ interface OlAuthorWorksResponse { // ─── Cache ────────────────────────────────────────────────────────────────── function cacheGet(key: string): T | undefined { - const entry = CACHE.get(key); - if (!entry) return undefined; - if (Date.now() - entry.timestamp > CACHE_TTL_MS) { - CACHE.delete(key); - return undefined; - } - return entry.data as T; + return cache.get(key) as T | undefined; } function cacheSet(key: string, data: unknown): void { - CACHE.set(key, { data, timestamp: Date.now() }); + cache.set(key, data); } /** Test helper — clears in-memory cache. */ export function clearOpenLibraryCache(): void { - CACHE.clear(); + cache.clear(); } // ─── ID helpers ───────────────────────────────────────────────────────────── @@ -262,22 +259,7 @@ export function coverUrlFromIsbn(isbn: string, size: 'S' | 'M' | 'L' = 'L'): str // ─── Fetch with timeout ───────────────────────────────────────────────────── async function fetchJson(url: string): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - try { - const res = await fetch(url, { - signal: controller.signal, - headers: { Accept: 'application/json' }, - }); - if (res.status === 404) return null; - if (!res.ok) return null; - return (await res.json()) as T; - } catch { - // AbortError, network failure, parse error — graceful empty - return null; - } finally { - clearTimeout(timer); - } + return fetchJsonWithTimeout(url, REQUEST_TIMEOUT_MS); } // ─── Mapping helpers ──────────────────────────────────────────────────────── diff --git a/src/background/tmdb.ts b/src/background/tmdb.ts index 99850e7..de97297 100644 --- a/src/background/tmdb.ts +++ b/src/background/tmdb.ts @@ -1,36 +1,25 @@ import { MediaItem, MediaType, MediaProvider, UserPreferences, CrewRole, StreamingInfo } from '@/shared/types'; import { getMediaItem, putMediaItem, getAllMediaMap, putMediaItems, getPreferences } from './storage'; import { fetchOmdbRatings, runWithConcurrency } from './omdb'; +import { BoundedTtlCache } from './ttlCache'; const BASE_URL = 'https://api.themoviedb.org/3'; export const POSTER_BASE_URL = 'https://image.tmdb.org/t/p/w500'; -const CACHE = new Map(); const CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours export const MAX_CACHE_SIZE = 500; - -function setCacheEntry(key: string, data: unknown): void { - if (CACHE.has(key)) { - CACHE.delete(key); - } else if (CACHE.size >= MAX_CACHE_SIZE) { - const oldestKey = CACHE.keys().next().value; - if (oldestKey !== undefined) { - CACHE.delete(oldestKey); - } - } - CACHE.set(key, { data, timestamp: Date.now() }); -} +const cache = new BoundedTtlCache(MAX_CACHE_SIZE, CACHE_TTL); export function clearTmdbCache(): void { - CACHE.clear(); + cache.clear(); } export function getTmdbCacheSizeForTesting(): number { - return CACHE.size; + return cache.size; } export function setTmdbCacheEntryForTesting(key: string, data: unknown): void { - setCacheEntry(key, data); + cache.set(key, data); } let tmdbApiKey: string | null = null; @@ -319,9 +308,9 @@ export async function fetchWatchProviders( options?: { releaseDate?: string } ): Promise { const cacheKey = `watch_providers_${type}_${tmdbNumericId}_${region}`; - const cached = CACHE.get(cacheKey); - if (cached && Date.now() - cached.timestamp < CACHE_TTL) { - return cached.data as StreamingInfo[]; + const cached = cache.get(cacheKey); + if (cached !== undefined) { + return cached as StreamingInfo[]; } try { @@ -342,7 +331,7 @@ export async function fetchWatchProviders( theatricalReleaseDates, }); - setCacheEntry(cacheKey, mapped); + cache.set(cacheKey, mapped); return mapped; } catch (err) { console.error('[Subsume TMDB] Watch providers fetch failed', err); @@ -418,9 +407,9 @@ export async function searchTitle( ): Promise { await loadGenreMap(); const cacheKey = `search_${title}_${yearGuess}_${typeGuess}`; - const cached = CACHE.get(cacheKey); - if (cached && Date.now() - cached.timestamp < CACHE_TTL) { - return cached.data as MediaItem | null; + const cached = cache.get(cacheKey); + if (cached !== undefined) { + return cached as MediaItem | null; } // If we don't know the type, we search both and take the best match. @@ -491,7 +480,7 @@ export async function searchTitle( const baseItem = mapTmdbToMediaItem(bestResult, finalType); const withRatings = await enrichMediaWithOmdbRatings(baseItem); const resultItem = await enrichMediaWithStreaming(withRatings, undefined, releaseDate); - setCacheEntry(cacheKey, resultItem); + cache.set(cacheKey, resultItem); return resultItem; } catch (err) { console.error('[Subsume TMDB] Search failed', err); @@ -506,9 +495,9 @@ export async function searchTitles( ): Promise { await loadGenreMap(); const cacheKey = `search_multi_${query}_${type}_${year}`; - const cached = CACHE.get(cacheKey); - if (cached && Date.now() - cached.timestamp < CACHE_TTL) { - return cached.data as MediaItem[] || []; + const cachedMulti = cache.get(cacheKey); + if (cachedMulti !== undefined) { + return (cachedMulti as MediaItem[]) || []; } const key = await ensureTmdbApiKey(); @@ -555,7 +544,7 @@ export async function searchTitles( enrichMediaWithOmdbRatings, 3 ); - setCacheEntry(cacheKey, limited); + cache.set(cacheKey, limited); return limited; } catch (err) { console.error('[Subsume TMDB] Multi-search failed', err); diff --git a/src/background/ttlCache.ts b/src/background/ttlCache.ts new file mode 100644 index 0000000..8c1a465 --- /dev/null +++ b/src/background/ttlCache.ts @@ -0,0 +1,42 @@ +/** FIFO Map with TTL — shared by TMDb, OMDb, Google Books, Open Library in-memory caches. */ + +export type TtlCacheEntry = { data: T; timestamp: number }; + +export class BoundedTtlCache { + private readonly store = new Map>(); + + constructor( + private readonly maxSize: number, + private readonly ttlMs: number, + ) {} + + get size(): number { + return this.store.size; + } + + get(key: string, now: number = Date.now()): T | undefined { + const entry = this.store.get(key); + if (!entry) return undefined; + if (now - entry.timestamp >= this.ttlMs) { + this.store.delete(key); + return undefined; + } + return entry.data; + } + + set(key: string, data: T, now: number = Date.now()): void { + if (this.store.has(key)) { + this.store.delete(key); + } else if (this.store.size >= this.maxSize) { + const oldestKey = this.store.keys().next().value; + if (oldestKey !== undefined) { + this.store.delete(oldestKey); + } + } + this.store.set(key, { data, timestamp: now }); + } + + clear(): void { + this.store.clear(); + } +} diff --git a/src/content/bookDetection/index.ts b/src/content/bookDetection/index.ts index 6be281b..0f067ea 100644 --- a/src/content/bookDetection/index.ts +++ b/src/content/bookDetection/index.ts @@ -18,6 +18,7 @@ import { } from './isbnContext'; import { detectViaDomainAdapters } from './domainAdapters'; import { detectTitleAuthorHeuristics } from './titleAuthorHeuristics'; +import { normalizeTitleKey } from '../titleKey'; export { detectJsonLdBooks } from './jsonLdBooks'; export { extractIsbnsFromDocument, candidatesFromIsbns } from './isbnContext'; @@ -29,14 +30,6 @@ export { detectTitleAuthorHeuristics } from './titleAuthorHeuristics'; export const MIN_ANNOTATION_CONFIDENCE = 0.65; export const HIGH_CONFIDENCE = 0.85; -function normalizeTitleKey(title: string): string { - return title - .toLowerCase() - .replace(/[^a-z0-9\s]/g, '') - .replace(/\s+/g, ' ') - .trim(); -} - function authorKey(authors?: string[]): string { return (authors || []) .map((a) => a.toLowerCase().replace(/[^a-z0-9\s]/g, '').trim()) diff --git a/src/content/bookOverlay.ts b/src/content/bookOverlay.ts index cc39a5d..ee30e22 100644 --- a/src/content/bookOverlay.ts +++ b/src/content/bookOverlay.ts @@ -260,9 +260,7 @@ function isHTMLElement(value: BookPlaqueAnchor): value is HTMLElement { return typeof HTMLElement !== 'undefined' && value instanceof HTMLElement; } -function truncateTitle(title: string, max = 36): string { - return truncateForExcerpt(title, max); -} + /** * Manager for book plaques on host pages. @@ -473,7 +471,7 @@ export class BookPlaqueManager { const title = document.createElement('span'); title.className = 'plaque-title'; - title.textContent = truncateTitle(match.title); + title.textContent = truncateForExcerpt(match.title, 36); title.title = match.title; openBtn.appendChild(title); @@ -512,7 +510,7 @@ export class BookPlaqueManager { const title = document.createElement('span'); title.className = 'plaque-title'; - title.textContent = truncateTitle(match.title); + title.textContent = truncateForExcerpt(match.title, 36); title.title = match.title; root.appendChild(title); diff --git a/src/content/index.ts b/src/content/index.ts index d625f46..a09405c 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -61,8 +61,7 @@ async function resolveAndAttachBookPlaques( ): Promise { const high = candidates .filter((c) => c.confidence >= HIGH_CONFIDENCE) - .slice(0, MAX_BOOK_PLAQUE_CANDIDATES) - .slice(0, MAX_BOOK_RESOLVE_CALLS); + .slice(0, Math.min(MAX_BOOK_PLAQUE_CANDIDATES, MAX_BOOK_RESOLVE_CALLS)); for (const candidate of high) { try { diff --git a/src/content/overlay.ts b/src/content/overlay.ts index b48a21c..76385a1 100644 --- a/src/content/overlay.ts +++ b/src/content/overlay.ts @@ -33,7 +33,6 @@ export function pickDisplayRating(ratings: MediaRating[]): { provider: RatingPro interface PlaqueProps { match: PosterMatch; - inLibrary: boolean; onReflect: () => void; } @@ -185,7 +184,6 @@ const PLAQUE_STYLES = ` interface BadgeState { match: PosterMatch; - inLibrary: boolean; host: HTMLElement; shadowRoot: ShadowRoot; mount: HTMLElement; @@ -194,13 +192,7 @@ interface BadgeState { export class MuseumPlaqueManager { private badges = new Map(); - private libraryIds = new Set(); private createdWrappers = new Set(); - private syncListener: ((message: unknown, sender: chrome.runtime.MessageSender) => void) | null = null; - - constructor() { - this.setupSyncListener(); - } private ensureWrapper(img: HTMLImageElement): HTMLElement { const parent = img.parentElement; @@ -217,39 +209,7 @@ export class MuseumPlaqueManager { return wrap; } - private setupSyncListener(): void { - if (typeof chrome === 'undefined' || !chrome.runtime?.onMessage) return; - this.syncListener = (message: unknown, sender: chrome.runtime.MessageSender) => { - if (sender.id !== chrome.runtime.id) return; - if (!message || typeof message !== 'object') return; - if (!('type' in message) || (message as Record).type !== 'LIBRARY_UPDATED') return; - - const msg = message as { mediaId?: unknown; libraryItem?: { mediaId?: unknown }; action?: unknown }; - const mediaId = typeof msg.mediaId === 'string' ? msg.mediaId : (typeof msg.libraryItem?.mediaId === 'string' ? msg.libraryItem.mediaId : undefined); - if (!mediaId) return; - - if (msg.action === 'add' || msg.action === 'update') { - this.libraryIds.add(mediaId); - } else if (msg.action === 'remove') { - this.libraryIds.delete(mediaId); - } - - for (const state of this.badges.values()) { - if (state.mediaId === mediaId) { - state.inLibrary = msg.action !== 'remove'; - this.renderBadge(state); - } - } - }; - chrome.runtime.onMessage.addListener(this.syncListener); - } - public destroy(): void { - if (this.syncListener && typeof chrome !== 'undefined' && chrome.runtime?.onMessage) { - chrome.runtime.onMessage.removeListener(this.syncListener); - this.syncListener = null; - } - for (const [img, state] of this.badges.entries()) { render(null, state.mount); state.host.remove(); @@ -274,7 +234,6 @@ export class MuseumPlaqueManager { } this.createdWrappers.clear(); this.badges.clear(); - this.libraryIds.clear(); } attachBadge(img: HTMLImageElement, match: PosterMatch): void { @@ -296,7 +255,6 @@ export class MuseumPlaqueManager { const state: BadgeState = { match, - inLibrary: match.inLibrary || this.libraryIds.has(mediaId), host, shadowRoot, mount, @@ -312,7 +270,6 @@ export class MuseumPlaqueManager { render( h(MuseumPlaqueOverlay, { match: state.match, - inLibrary: state.inLibrary, onReflect: () => this.handleReflect(state), }), state.mount diff --git a/src/content/screenDetection/index.ts b/src/content/screenDetection/index.ts index 78c8271..95461a9 100644 --- a/src/content/screenDetection/index.ts +++ b/src/content/screenDetection/index.ts @@ -8,6 +8,7 @@ import { type ScreenDetectionCandidate, } from './jsonLdScreen'; import { detectViaScreenDomainAdapters } from './domainAdapters'; +import { normalizeTitleKey } from '../titleKey'; export type { ScreenDetectionCandidate, ScreenKind } from './jsonLdScreen'; export { detectJsonLdScreenWorks } from './jsonLdScreen'; @@ -19,14 +20,6 @@ export { /** Minimum confidence to report a screen page candidate (parity with book annotation floor). */ export const MIN_SCREEN_PAGE_CONFIDENCE = 0.65; -function normalizeTitleKey(title: string): string { - return title - .toLowerCase() - .replace(/[^a-z0-9\s]/g, '') - .replace(/\s+/g, ' ') - .trim(); -} - function mergeScreen( a: ScreenDetectionCandidate, b: ScreenDetectionCandidate diff --git a/src/content/titleKey.ts b/src/content/titleKey.ts new file mode 100644 index 0000000..a525dd0 --- /dev/null +++ b/src/content/titleKey.ts @@ -0,0 +1,8 @@ +/** Shared title key for book/screen candidate dedupe. */ +export function normalizeTitleKey(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 4258cf2..c60aaff 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -122,42 +122,25 @@ export function createMessageRouter( return false; } - if (options?.onBeforeDispatch) { - Promise.resolve(options.onBeforeDispatch()) - .catch((err) => { - console.error('[Subsume] Error in onBeforeDispatch:', err); - }) - .then(() => handler(message.payload, sender)) - .then((data) => sendResponse({ success: true, data })) - .catch((err) => { - const msg = err instanceof Error ? err.message : String(err); - logDiagnostic('error', 'bg.handler', msg, `type=${message.type}`); - sendResponse({ - success: false, - error: msg, - }); - }); - return true; - } - - const result = handler(message.payload, sender); + const run = options?.onBeforeDispatch + ? Promise.resolve(options.onBeforeDispatch()) + .catch((err) => { + console.error('[Subsume] Error in onBeforeDispatch:', err); + }) + .then(() => handler(message.payload, sender)) + : handler(message.payload, sender); - if (result instanceof Promise) { - result - .then((data) => sendResponse({ success: true, data })) - .catch((err) => { - const msg = err instanceof Error ? err.message : String(err); - logDiagnostic('error', 'bg.handler', msg, `type=${message.type}`); - sendResponse({ - success: false, - error: msg, - }); + Promise.resolve(run) + .then((data) => sendResponse({ success: true, data })) + .catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + logDiagnostic('error', 'bg.handler', msg, `type=${message.type}`); + sendResponse({ + success: false, + error: msg, }); - return true; - } - - sendResponse({ success: true, data: result }); - return false; + }); + return true; } ); } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index a0c561a..38ff73b 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -16,11 +16,11 @@ import { FIRST_INSCRIPTION_GATE_SESSION_KEY, } from './components/FirstInscriptionGate'; import { sendMessage } from '../shared/messages'; -import { MessageType, UserPreferences, LibraryItem, MediaItem, PersonItem } from '../shared/types'; +import { MessageType, UserPreferences } from '../shared/types'; import { usePrefetch, prefetchPage, prefetchProps, type Page } from './hooks/usePrefetch'; import { applyThemePreference, applyCinemaAtmosphere, watchSystemTheme } from '../shared/theme'; import { FilmGrain } from './components/FilmGrain'; -import { ensureDemoLibraryIfEmpty, seedPracticeLibraryIfEmpty } from './lib/ensureDemoLibrary'; +import { seedPracticeLibraryIfEmpty } from './lib/ensureDemoLibrary'; import { useNotice } from './components/NoticeProvider'; import { formatUserError } from './utils/formatUserError'; import { Icon, type IconName } from './components/icons'; @@ -32,11 +32,6 @@ import './styles/app-nav.css'; const DRAWER_FOCUSABLE_SELECTOR = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; -interface LibraryStats { - movieCount: number; - tvCount: number; -} - interface NavItem { key: Page; label: string; @@ -80,18 +75,19 @@ function getInitialPage(): Page { return 'home'; } -function NavIcon({ item }: { item: NavItem }) { - if (item.icon === 'I' || item.icon === 'II' || item.icon === 'III') { - return {item.icon}; - } - return ; -} - -function ExploreIcon({ name }: { name: IconName | string }) { +function NavGlyph({ + name, + size, + className, +}: { + name: IconName | 'I' | 'II' | 'III'; + size: number; + className: string; +}) { if (name === 'I' || name === 'II' || name === 'III') { return {name}; } - return ; + return ; } export function App() { @@ -106,8 +102,6 @@ export function App() { return null; }); const [prefs, setPrefs] = useState(null); - const [stats, setStats] = useState({ movieCount: 0, tvCount: 0 }); - const [peopleCount, setPeopleCount] = useState(0); const initialPrefetchDone = useRef(false); const appOpenCounted = useRef(false); const menuToggleRef = useRef(null); @@ -173,18 +167,6 @@ export function App() { } } }).catch(() => {}); - - ensureDemoLibraryIfEmpty().then((library) => { - const movieCount = library.filter((item) => item.media?.type === 'movie').length; - const tvCount = library.filter((item) => item.media?.type === 'tv').length; - setStats({ movieCount, tvCount }); - }).catch(() => {}); - - sendMessage, { people: PersonItem[] }>(MessageType.GET_ALL_PEOPLE, {}).then((res) => { - if (res.success && res.data?.people) { - setPeopleCount(res.data.people.length); - } - }).catch(() => {}); }, []); // After first archive add, refresh prefs so the first-inscription gate can dismiss @@ -221,20 +203,6 @@ export function App() { } }, [currentPage, prefetchPageOnMount]); - useEffect(() => { - const handleMessage = (message: unknown) => { - if (message && typeof message === 'object' && 'type' in message && (message as Record).type === 'FILMMAKERS_UPDATED') { - sendMessage, { people: PersonItem[] }>(MessageType.GET_ALL_PEOPLE, {}).then((res) => { - if (res.success && res.data?.people) { - setPeopleCount(res.data.people.length); - } - }).catch(() => {}); - } - }; - chrome.runtime.onMessage.addListener(handleMessage); - return () => chrome.runtime.onMessage.removeListener(handleMessage); - }, []); - // Drawer open: move focus in, trap Tab, Esc closes (interruptible spring) useEffect(() => { if (!drawerOpen) return; @@ -477,7 +445,7 @@ export function App() { aria-current={currentPage === item.key ? 'page' : undefined} {...prefetchProps(item.key)} > - + {item.label} ))} @@ -529,7 +497,7 @@ export function App() { onClick={() => goToPage(item.key)} {...prefetchProps(item.key)} > - + {item.label} ))} @@ -545,7 +513,7 @@ export function App() { onClick={() => goToPage(item.key)} {...prefetchProps(item.key)} > - + {item.label} ))} @@ -561,7 +529,7 @@ export function App() { onClick={() => goToPage(item.key)} {...prefetchProps(item.key)} > - + {item.label} ))} diff --git a/src/ui/lib/ensureDemoLibrary.ts b/src/ui/lib/ensureDemoLibrary.ts index 993ec79..d24db83 100644 --- a/src/ui/lib/ensureDemoLibrary.ts +++ b/src/ui/lib/ensureDemoLibrary.ts @@ -1,10 +1,8 @@ import { sendMessage } from '@/shared/messages'; -import { MessageType, LibraryItem, MediaItem } from '@/shared/types'; +import { MessageType } from '@/shared/types'; +import type { JoinedItem } from '../components/archive/types'; -export interface JoinedLibraryItem { - library: LibraryItem; - media: MediaItem; -} +export type JoinedLibraryItem = JoinedItem; /** * Loads the current library. Does **not** auto-seed demo titles. diff --git a/src/ui/pages/Home.tsx b/src/ui/pages/Home.tsx index 49baff3..2051f9d 100644 --- a/src/ui/pages/Home.tsx +++ b/src/ui/pages/Home.tsx @@ -3,7 +3,6 @@ import { sendMessage } from '@/shared/messages'; import { MessageType, MediaItem, - LibraryItem, Recommendation, UserPreferences, WeeklyDigest, @@ -38,11 +37,7 @@ import { } from '@/shared/productCopy'; import { incrementWeeklySelectionOpens } from '@/shared/activationMetrics'; import { shouldShowDiscoveryFirstInscriptionBanner } from '../components/FirstInscriptionGate'; - -interface JoinedItem { - library: LibraryItem; - media: MediaItem; -} +import type { JoinedItem } from '../components/archive/types'; interface DigestPick extends WeeklyDigestItem { media?: MediaItem; diff --git a/src/ui/pages/Stats.tsx b/src/ui/pages/Stats.tsx index 321c63c..64400a3 100644 --- a/src/ui/pages/Stats.tsx +++ b/src/ui/pages/Stats.tsx @@ -1,7 +1,8 @@ import { h } from 'preact'; import { useState, useEffect, useMemo } from 'preact/hooks'; import { sendMessage } from '@/shared/messages'; -import { MessageType, LibraryItem, MediaItem } from '@/shared/types'; +import { MessageType } from '@/shared/types'; +import type { JoinedItem } from '../components/archive/types'; import { computeBookReadingStats, hasBookReadingActivity, @@ -17,11 +18,6 @@ import { } from '@/shared/productCopy'; import { EmotionalWeatherChart } from '../components/EmotionalWeatherChart'; -interface JoinedItem { - library: LibraryItem; - media: MediaItem; -} - interface StatsProps { onNavigate?: (page: 'search' | 'library' | 'home') => void; } diff --git a/src/ui/popup.tsx b/src/ui/popup.tsx index 3f7d786..b1d62d6 100644 --- a/src/ui/popup.tsx +++ b/src/ui/popup.tsx @@ -1,7 +1,7 @@ import { render, h } from 'preact'; import { useEffect, useState, useRef } from 'preact/hooks'; import { sendMessage } from '../shared/messages'; -import { MessageType, UserPreferences, LibraryItem, MediaItem, SanctuaryIntent, LibraryStatus } from '../shared/types'; +import { MessageType, UserPreferences, MediaItem, SanctuaryIntent, LibraryStatus } from '../shared/types'; import { applyThemePreference, applyCinemaAtmosphere, watchSystemTheme } from '../shared/theme'; import { DEFAULT_EMOTIONS, type EmotionalSpectrum } from '../shared/emotions'; import { EmotionalSliders } from './components/EmotionalSliders'; @@ -31,11 +31,7 @@ import '../shared/tokens.css'; import './styles/popup.css'; import './styles/emotional-components.css'; import './components/inline-notice.css'; - -interface JoinedItem { - library: LibraryItem; - media: MediaItem; -} +import type { JoinedItem } from './components/archive/types'; function openSanctuary(page?: string) { const url = chrome.runtime.getURL(`ui/index.html${page ? `?page=${page}` : ''}`); diff --git a/tests/messageRouter.characterization.test.ts b/tests/messageRouter.characterization.test.ts new file mode 100644 index 0000000..e058c91 --- /dev/null +++ b/tests/messageRouter.characterization.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createMessageRouter } from '@/shared/messages'; +import { MessageType } from '@/shared/types'; + +type MessageListener = ( + message: { type: MessageType; payload: unknown }, + sender: chrome.runtime.MessageSender, + sendResponse: (response: { success: boolean; data?: unknown; error?: string }) => void +) => boolean | void; + +describe('characterization: message router always returns true after dispatch', () => { + const extensionOrigin = 'chrome-extension://test-extension-id/'; + let listener: MessageListener; + + function install(handlers: Parameters[0], options?: Parameters[1]) { + createMessageRouter(handlers, options); + listener = vi.mocked(chrome.runtime.onMessage.addListener).mock.calls.at(-1)![0] as MessageListener; + } + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(chrome.runtime.getURL).mockReturnValue(extensionOrigin); + }); + + function dispatch( + type: MessageType, + payload: unknown, + handlerSetup: () => void + ): Promise<{ syncReturn: boolean | void; responses: unknown[] }> { + handlerSetup(); + const responses: unknown[] = []; + const sender = { url: `${extensionOrigin}ui/index.html` } as chrome.runtime.MessageSender; + const syncReturn = listener({ type, payload }, sender, (r) => { + responses.push(r); + }); + return new Promise((resolve) => { + queueMicrotask(() => resolve({ syncReturn, responses })); + }); + } + + it('sync handler: one sendResponse, return true', async () => { + const { syncReturn, responses } = await dispatch(MessageType.GET_LIBRARY, { x: 1 }, () => { + install({ [MessageType.GET_LIBRARY]: () => ({ ok: true }) }); + }); + await new Promise((r) => setTimeout(r, 0)); + expect(syncReturn).toBe(true); + expect(responses).toEqual([{ success: true, data: { ok: true } }]); + }); + + it('async handler: one sendResponse, return true', async () => { + install({ + [MessageType.GET_LIBRARY]: async () => { + await Promise.resolve(); + return { async: true }; + }, + }); + const responses: unknown[] = []; + const syncReturn = listener( + { type: MessageType.GET_LIBRARY, payload: {} }, + { url: `${extensionOrigin}ui/index.html` } as chrome.runtime.MessageSender, + (r) => responses.push(r) + ); + expect(syncReturn).toBe(true); + await vi.waitFor(() => { + expect(responses).toEqual([{ success: true, data: { async: true } }]); + }); + }); + + it('async throwing handler: one error sendResponse, return true', async () => { + install({ + [MessageType.GET_LIBRARY]: async () => { + throw new Error('boom-handler'); + }, + }); + const responses: unknown[] = []; + const syncReturn = listener( + { type: MessageType.GET_LIBRARY, payload: {} }, + { url: `${extensionOrigin}ui/index.html` } as chrome.runtime.MessageSender, + (r) => responses.push(r) + ); + expect(syncReturn).toBe(true); + await vi.waitFor(() => { + expect(responses).toEqual([{ success: false, error: 'boom-handler' }]); + }); + }); + + it('onBeforeDispatch runs then handler payload is unchanged', async () => { + const before = vi.fn(async () => { + throw new Error('before-fail'); + }); + install( + { [MessageType.GET_LIBRARY]: () => ({ after: true }) }, + { onBeforeDispatch: before } + ); + const responses: unknown[] = []; + const syncReturn = listener( + { type: MessageType.GET_LIBRARY, payload: { keep: 1 } }, + { url: `${extensionOrigin}ui/index.html` } as chrome.runtime.MessageSender, + (r) => responses.push(r) + ); + expect(syncReturn).toBe(true); + await vi.waitFor(() => { + expect(before).toHaveBeenCalledOnce(); + expect(responses).toEqual([{ success: true, data: { after: true } }]); + }); + }); +}); diff --git a/tests/overlay.test.ts b/tests/overlay.test.ts index 86cb340..6480604 100644 --- a/tests/overlay.test.ts +++ b/tests/overlay.test.ts @@ -119,7 +119,7 @@ describe('Act I Discovery Plaque Injection (overlay.ts)', () => { }); describe('destroy', () => { - it('unmounts Preact roots and removes message listeners', () => { + it('unmounts Preact roots and removes plaque hosts', () => { const manager = new MuseumPlaqueManager(); const img = document.createElement('img'); document.body.appendChild(img); @@ -135,11 +135,9 @@ describe('Act I Discovery Plaque Injection (overlay.ts)', () => { }; manager.attachBadge(img, match); - expect(chrome.runtime.onMessage.addListener).toHaveBeenCalled(); manager.destroy(); - expect(chrome.runtime.onMessage.removeListener).toHaveBeenCalled(); const host = document.body.querySelector('div[data-subsume-badge]'); expect(host).toBeNull(); });