diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index 74d5df9..c7467b4 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -1,3 +1,20 @@ +### [2026-01-07 05:04] [Agent: Codex] +**Status:** Starting +**Task:** Add structured EPUB export warnings (missing translations, cache misses) and package validation logs without changing output. +**Worktree:** ../LexiconForge.worktrees/codex-epub-diagnostics/ +**Branch:** feat/codex-epub-diagnostics +**Files likely affected:** store/slices/exportSlice.ts; services/epubService/packagers/epubPackager.ts; tests/services/epubPackager.diagnostics.test.ts; docs/WORKLOG.md + +### [2026-01-07 12:21] [Agent: Codex] +**Status:** Complete +**Progress:** Added structured export warnings for missing translations/cache misses, added EPUB package validation warnings, and added diagnostics test coverage. +**Files modified (line numbers + why):** +- store/slices/exportSlice.ts:31,270,342,405,442,587 - track/export structured warnings, log to telemetry, surface warning counts in progress + performance telemetry. +- services/epubService/packagers/epubPackager.ts:15,21,27,33,146,307 - emit package validation warnings (missing title/identifier, no chapters, invalid cover image, XHTML parse errors). +- tests/services/epubPackager.diagnostics.test.ts:1,4,32 - verify structured warnings for missing title and invalid cover image. +- docs/WORKLOG.md:1 - session log updates. +**Tests:** npx vitest run tests/services/epubPackager.diagnostics.test.ts + 2025-12-26 20:31 UTC - Provider contract VCR replay tests - Files: tests/contracts/provider.contract.test.ts; tests/contracts/vcr/loadCassette.ts; tests/contracts/vcr/types.ts; tests/contracts/cassettes/*.json; docs/WORKLOG.md - Why: Provider contract tests were a skipped scaffold; we need deterministic replay tests to validate real adapter behavior without network calls and without placeholder assertions. diff --git a/services/epubService/packagers/epubPackager.ts b/services/epubService/packagers/epubPackager.ts index ac806e6..47f266c 100644 --- a/services/epubService/packagers/epubPackager.ts +++ b/services/epubService/packagers/epubPackager.ts @@ -9,6 +9,32 @@ import { EPUB_STYLESHEET_CSS } from './stylesheet'; export const generateEpub3WithJSZip = async (meta: EpubMeta, chapters: EpubChapter[]): Promise => { const lang = meta.language || 'en'; const bookId = meta.identifier || `urn:uuid:${crypto.randomUUID()}`; + + type PackageWarning = { type: string; message: string; details?: Record }; + const packageWarnings: PackageWarning[] = []; + const recordWarning = (warning: PackageWarning) => { + packageWarnings.push(warning); + console.warn('[EPUBPackager]', warning); + }; + + if (!meta.title || meta.title.trim().length === 0) { + recordWarning({ + type: 'missing-title', + message: 'EPUB package metadata is missing a title.' + }); + } + if (!meta.identifier) { + recordWarning({ + type: 'missing-identifier', + message: 'EPUB package metadata is missing an identifier; generated UUID will be used.' + }); + } + if (chapters.length === 0) { + recordWarning({ + type: 'no-chapters', + message: 'EPUB package contains no chapters.' + }); + } // EPUB3 directory structure const oebps = 'OEBPS'; @@ -116,6 +142,13 @@ export const generateEpub3WithJSZip = async (meta: EpubMeta, chapters: EpubChapt }; imageEntries.push(coverEntry); } + if (!coverEntry) { + recordWarning({ + type: 'invalid-cover-image', + message: 'Cover image was provided but is not a valid base64 data URL.', + details: { sample: meta.coverImage.slice(0, 32) } + }); + } } // Generate cover page XHTML if we have a cover @@ -271,6 +304,11 @@ export const generateEpub3WithJSZip = async (meta: EpubMeta, chapters: EpubChapt processedChapters.forEach(({ ch, xhtml }) => { zip.file(`${oebps}/debug/text/${ch.href}.raw.xhtml`, xhtml); }); + recordWarning({ + type: 'xhtml-parse-error', + message: `Detected ${parseErrors.length} XHTML parse error(s) during packaging.`, + details: { count: parseErrors.length } + }); } // Generate and return ArrayBuffer diff --git a/store/slices/exportSlice.ts b/store/slices/exportSlice.ts index fc658d4..ffb0e04 100644 --- a/store/slices/exportSlice.ts +++ b/store/slices/exportSlice.ts @@ -28,6 +28,19 @@ export interface ExportSlice { exportEpub: () => Promise; } +type ExportWarningType = 'missing-translation' | 'image-cache-miss'; + +interface ExportWarning { + type: ExportWarningType; + message: string; + chapterStableId?: string; + chapterId?: string; + chapterTitle?: string; + chapterUrl?: string; + marker?: string; + details?: Record; +} + // Debug logging utilities (copied from old store) const storeDebugEnabled = (): boolean => { try { @@ -253,6 +266,19 @@ export const createExportSlice: StateCreator< const { imageVersions, activeImageVersion } = get(); const setProgress = get().setExportProgress; const chaptersForEpub: import('../../services/epubService').ChapterForEpub[] = []; + const warnings: ExportWarning[] = []; + const recordWarning = (warning: ExportWarning) => { + warnings.push(warning); + telemetryService.captureWarning('export-epub', warning.message, { + type: warning.type, + chapterStableId: warning.chapterStableId, + chapterId: warning.chapterId, + chapterTitle: warning.chapterTitle, + chapterUrl: warning.chapterUrl, + marker: warning.marker, + details: warning.details, + }); + }; try { setProgress({ phase: 'preparing', current: 0, total: 1, message: 'Loading chapters...' }); @@ -312,7 +338,18 @@ export const createExportSlice: StateCreator< const ch = byStableId.get(sid); if (!ch) continue; const active = await TranslationOps.getActiveByStableId(sid); - if (!active) continue; + if (!active) { + recordWarning({ + type: 'missing-translation', + message: `Missing active translation for "${ch.title || 'Untitled'}" (${sid}).`, + chapterStableId: sid, + chapterId: ch.id, + chapterTitle: ch.title, + chapterUrl: ch.url, + details: { chapterNumber: ch.chapterNumber } + }); + continue; + } processedChapters++; setProgress({ @@ -333,6 +370,7 @@ export const createExportSlice: StateCreator< suggestedIllustrations.map(async (illust: any) => { // Check if this illustration has a cache key (modern versioned images) const imageCacheKey = illust.generatedImage?.imageCacheKey || illust.imageCacheKey; + const legacyUrl = (illust as any).url; const versionKey = ch.id && illust.placementMarker ? `${ch.id}:${illust.placementMarker}` : null; @@ -363,10 +401,24 @@ export const createExportSlice: StateCreator< prompt: caption }; } + + recordWarning({ + type: 'image-cache-miss', + message: `Image cache miss for "${illust.placementMarker}" in "${ch.title || 'Untitled'}" (${sid}).`, + chapterStableId: sid, + chapterId: ch.id, + chapterTitle: ch.title, + chapterUrl: ch.url, + marker: illust.placementMarker, + details: { + cacheKey: versionedKey, + fallbackUsed: Boolean(legacyUrl), + reason: 'miss' + } + }); } // Legacy fallback: use .url field (base64 data URL) - const legacyUrl = (illust as any).url; if (legacyUrl) { const metadata: ImageGenerationMetadata | undefined = versionStateEntry?.versions?.[version]; const caption = buildImageCaption(version, metadata, illust.imagePrompt); @@ -381,8 +433,29 @@ export const createExportSlice: StateCreator< return null; } catch (error) { console.error(`[Export] Failed to retrieve image for marker ${illust.placementMarker}:`, error); + if (imageCacheKey && ch.id) { + const cacheKey = { + chapterId: ch.id, + placementMarker: illust.placementMarker, + version: version + }; + recordWarning({ + type: 'image-cache-miss', + message: `Image cache lookup failed for "${illust.placementMarker}" in "${ch.title || 'Untitled'}" (${sid}).`, + chapterStableId: sid, + chapterId: ch.id, + chapterTitle: ch.title, + chapterUrl: ch.url, + marker: illust.placementMarker, + details: { + cacheKey, + fallbackUsed: Boolean(legacyUrl), + reason: 'error', + error: error instanceof Error ? error.message : String(error) + } + }); + } // Try legacy fallback on error - const legacyUrl = (illust as any).url; if (legacyUrl) { const metadata: ImageGenerationMetadata | undefined = versionStateEntry?.versions?.[version]; const caption = buildImageCaption(version, metadata, illust.imagePrompt); @@ -505,14 +578,21 @@ export const createExportSlice: StateCreator< ? performance.now() : Date.now(); const totalImages = chaptersForEpub.reduce((sum, chapter) => sum + (chapter.images?.length ?? 0), 0); + const warningTypes = Array.from(new Set(warnings.map(warning => warning.type))); telemetryService.capturePerformance('ux:export:epub', end - start, { chapterCount: chaptersForEpub.length, imageCount: totalImages, includeTitlePage: !!settings.includeTitlePage, - includeStatsPage: !!settings.includeStatsPage + includeStatsPage: !!settings.includeStatsPage, + warningCount: warnings.length, + warningTypes: warningTypes.length > 0 ? warningTypes : undefined }); - setProgress({ phase: 'done', current: 1, total: 1, message: 'EPUB export complete!' }); + const warningLabel = warnings.length === 1 ? 'warning' : 'warnings'; + const completionMessage = warnings.length > 0 + ? `EPUB export complete with ${warnings.length} ${warningLabel}.` + : 'EPUB export complete!'; + setProgress({ phase: 'done', current: 1, total: 1, message: completionMessage }); } catch (e: any) { setProgress(null); console.error('[Export] EPUB generation failed', e); diff --git a/tests/services/epubPackager.diagnostics.test.ts b/tests/services/epubPackager.diagnostics.test.ts new file mode 100644 index 0000000..d774679 --- /dev/null +++ b/tests/services/epubPackager.diagnostics.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest'; +import { generateEpub3WithJSZip } from '../../services/epubService/packagers/epubPackager'; + +describe('epubPackager diagnostics', () => { + it('emits structured warnings for missing title and invalid cover image', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const meta = { + title: ' ', + author: 'Test Author', + language: 'en', + identifier: 'urn:uuid:test', + coverImage: 'not-a-data-url' + }; + const chapters = [ + { + id: 'ch-001', + title: 'Chapter 1', + xhtml: '

Broken', + href: 'chapter-0001.xhtml' + } + ]; + + await generateEpub3WithJSZip(meta, chapters); + + const structuredWarnings = warnSpy.mock.calls + .filter(call => call[0] === '[EPUBPackager]') + .map(call => call[1] as { type?: string }); + const types = structuredWarnings.map(warning => warning.type); + + expect(types).toContain('missing-title'); + expect(types).toContain('invalid-cover-image'); + } finally { + warnSpy.mockRestore(); + } + }); +});