Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
38 changes: 38 additions & 0 deletions services/epubService/packagers/epubPackager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,32 @@ import { EPUB_STYLESHEET_CSS } from './stylesheet';
export const generateEpub3WithJSZip = async (meta: EpubMeta, chapters: EpubChapter[]): Promise<ArrayBuffer> => {
const lang = meta.language || 'en';
const bookId = meta.identifier || `urn:uuid:${crypto.randomUUID()}`;

type PackageWarning = { type: string; message: string; details?: Record<string, unknown> };
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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
90 changes: 85 additions & 5 deletions store/slices/exportSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ export interface ExportSlice {
exportEpub: () => Promise<void>;
}

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<string, unknown>;
}

// Debug logging utilities (copied from old store)
const storeDebugEnabled = (): boolean => {
try {
Expand Down Expand Up @@ -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...' });
Expand Down Expand Up @@ -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({
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
38 changes: 38 additions & 0 deletions tests/services/epubPackager.diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<p>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();
}
});
});
Loading