import { isHtmlLocator, isPdfLocator, isStableEpubLocator, type TTSSegmentLocator, } from '@/types/client'; import type { TTSLocation } from '@/types/tts'; export function normalizeTtsLocationKey(location: TTSLocation): string { return typeof location === 'number' ? `num:${location}` : `str:${location}`; } /** * Normalize an EPUB CFI string for whitespace and step-marker variations so two * CFIs from the same epub.js rendition can be compared by equality. This is * only useful for in-flight CFI deduplication (same device, same render); it * does NOT make CFIs stable across viewports — that's what the spine * coordinates on the locator are for. */ export function normalizeEpubLocationToken(location: string): string { return location .trim() .replace(/\[;s=[ab]\]/gi, '') .replace(/\s+/g, ''); } /** * Coarse grouping key used by the sidebar to bucket rows under a chapter * heading. EPUB groups by spine item (chapter/section), which is * viewport-independent. PDF groups by page. HTML groups by the free-form * location string. Multiple rows in the same chapter share this key — that's * the whole point. * * **Do NOT use for storage dedupe.** For that you want `locatorIdentityKey`, * which carries the full per-row identity (including `charOffset` for EPUB). * * Legacy EPUB rows that only carry a CFI (no spine coords) fall back to a * group keyed by the raw CFI string — they keep working in isolation but will * not co-group with new-shape rows. Such rows are expected to disappear once * the user clears the legacy manifest. */ export function locatorGroupKey(locator: TTSSegmentLocator | null): string { if (!locator) return 'none'; if (isStableEpubLocator(locator)) { return `epub:${locator.spineIndex}:${locator.spineHref}`; } if (isPdfLocator(locator)) { return `pdf:${Math.floor(locator.page)}`; } if (isHtmlLocator(locator)) { return `html:${locator.location}`; } // Legacy / draft fallback. Keeps the row identifiable but does not promise // cross-viewport stability. const readerType = locator.readerType || '?'; const fallback = locator.location ?? (typeof locator.page === 'number' ? String(locator.page) : ''); return `legacy:${readerType}:${fallback}`; } /** * Per-row identity key. Unlike `locatorGroupKey`, this includes the full * locator-level identity (e.g. `charOffset` for EPUB) so two rows at different * positions inside the same spine item never collapse into one entry. Used by * the server-side manifest aggregator and anywhere we need "is this the same * persisted row?" — not for sidebar chapter buckets. */ export function locatorIdentityKey(locator: TTSSegmentLocator | null): string { if (!locator) return 'none'; if (isStableEpubLocator(locator)) { return `epub:${locator.spineIndex}:${locator.spineHref}:${locator.charOffset}`; } if (isPdfLocator(locator)) { const blockPart = typeof locator.blockId === 'string' && locator.blockId.trim() ? `:${locator.blockId.trim()}` : ''; return `pdf:${Math.floor(locator.page)}${blockPart}`; } if (isHtmlLocator(locator)) { return `html:${locator.location}`; } const readerType = locator.readerType || '?'; const fallback = locator.location ?? (typeof locator.page === 'number' ? String(locator.page) : ''); return `legacy:${readerType}:${fallback}`; } function readerTypeRank(readerType: string | undefined): number { if (readerType === 'epub') return 0; if (readerType === 'pdf') return 1; if (readerType === 'html') return 2; return 3; } /** * Total order over locators. Within a readerType: * - EPUB (stable shape): (spineIndex, charOffset). Both numeric — stable * across viewports. * - PDF: page number. * - HTML: location string (lexicographic). * Across readerTypes (rarely mixed in practice): stable rank by readerType. * Null locators sort last. */ export function compareSegmentLocators( a: TTSSegmentLocator | null, b: TTSSegmentLocator | null, ): number { if (!a && !b) return 0; if (!a) return 1; if (!b) return -1; if (a.readerType !== b.readerType) { return readerTypeRank(a.readerType) - readerTypeRank(b.readerType); } if (isStableEpubLocator(a) && isStableEpubLocator(b)) { if (a.spineIndex !== b.spineIndex) return a.spineIndex - b.spineIndex; if (a.charOffset !== b.charOffset) return a.charOffset - b.charOffset; return a.spineHref.localeCompare(b.spineHref); } if (isPdfLocator(a) && isPdfLocator(b)) { const pageCmp = Math.floor(a.page) - Math.floor(b.page); if (pageCmp !== 0) return pageCmp; const aBlock = typeof a.blockId === 'string' ? a.blockId : ''; const bBlock = typeof b.blockId === 'string' ? b.blockId : ''; return aBlock.localeCompare(bBlock); } if (isHtmlLocator(a) && isHtmlLocator(b)) { // When both locations look like positive integers (HTML reader blocks), // compare numerically so "10" sorts after "2" instead of between "1" and // "2". Falls back to lexicographic for legacy free-form locations. const an = /^\d+$/.test(a.location) ? Number(a.location) : NaN; const bn = /^\d+$/.test(b.location) ? Number(b.location) : NaN; if (Number.isFinite(an) && Number.isFinite(bn)) { return an - bn; } return a.location.localeCompare(b.location); } // One or both are legacy/draft — fall back to grouped-key compare so the // sort is at least deterministic and self-consistent. return locatorGroupKey(a).localeCompare(locatorGroupKey(b)); }