import * as fs from 'fs'; import * as path from 'path'; import type { CharacterNameImage } from '../../types'; import { readCachedSnapshots } from './cache'; import type { CharacterDictionaryGlossaryEntry, CharacterDictionarySnapshot, CharacterDictionarySnapshotImage, CharacterDictionaryTermEntry, } from './types'; const CHARACTER_IMAGE_PATH_PATTERN = /^img\/m\d+-c\d+\.[a-z0-9]+$/i; type StructuredContentNode = { tag?: unknown; path?: unknown; alt?: unknown; title?: unknown; content?: unknown; }; function normalizeLookupTerm(term: string): string { return term.trim(); } function normalizeLookupMediaId(mediaId: unknown): number | null { if (typeof mediaId !== 'number' || !Number.isFinite(mediaId)) { return null; } const normalized = Math.floor(mediaId); return normalized > 0 ? normalized : null; } function getSnapshotsDir(outputDir: string): string { return path.join(outputDir, 'snapshots'); } function getImageMimeType(imagePath: string, dataBase64: string): string { const signature = Buffer.from(dataBase64.slice(0, 64), 'base64'); if ( signature.length >= 8 && signature[0] === 0x89 && signature[1] === 0x50 && signature[2] === 0x4e && signature[3] === 0x47 ) { return 'image/png'; } if ( signature.length >= 12 && signature.subarray(0, 4).toString('ascii') === 'RIFF' && signature.subarray(8, 12).toString('ascii') === 'WEBP' ) { return 'image/webp'; } if ( signature.length >= 6 && (signature.subarray(0, 6).toString('ascii') === 'GIF89a' || signature.subarray(0, 6).toString('ascii') === 'GIF87a') ) { return 'image/gif'; } if (signature.length >= 3 && signature[0] === 0xff && signature[1] === 0xd8) { return 'image/jpeg'; } if ( signature.length >= 12 && signature.subarray(4, 8).toString('ascii') === 'ftyp' && signature.subarray(8, 12).toString('ascii') === 'avif' ) { return 'image/avif'; } const ext = path.extname(imagePath).toLowerCase(); if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; if (ext === '.png') return 'image/png'; if (ext === '.webp') return 'image/webp'; if (ext === '.gif') return 'image/gif'; if (ext === '.avif') return 'image/avif'; return 'image/jpeg'; } function buildImageByPath( images: ReadonlyArray, ): Map { const imageByPath = new Map(); for (const image of images) { if (image.path && image.dataBase64) { imageByPath.set(image.path, image); } } return imageByPath; } function findCharacterImageNode(value: unknown): StructuredContentNode | null { if (Array.isArray(value)) { for (const item of value) { const found = findCharacterImageNode(item); if (found) return found; } return null; } if (!value || typeof value !== 'object') { return null; } const node = value as StructuredContentNode; if ( node.tag === 'img' && typeof node.path === 'string' && CHARACTER_IMAGE_PATH_PATTERN.test(node.path) ) { return node; } return findCharacterImageNode(node.content); } function findCharacterImageNodeInGlossary( glossary: ReadonlyArray, ): StructuredContentNode | null { for (const entry of glossary) { const found = findCharacterImageNode(entry); if (found) return found; } return null; } function createCharacterNameImage( entry: CharacterDictionaryTermEntry, imageByPath: ReadonlyMap, ): CharacterNameImage | null { const term = normalizeLookupTerm(entry[0]); if (!term) { return null; } const imageNode = findCharacterImageNodeInGlossary(entry[5]); const imagePath = typeof imageNode?.path === 'string' ? imageNode.path : ''; const image = imageByPath.get(imagePath); if (!image) { return null; } const rawAlt = typeof imageNode?.alt === 'string' ? imageNode.alt : typeof imageNode?.title === 'string' ? imageNode.title : term; const alt = rawAlt.trim() || term; return { src: `data:${getImageMimeType(image.path, image.dataBase64)};base64,${image.dataBase64}`, alt, }; } function appendSnapshotImages( index: Map, snapshot: CharacterDictionarySnapshot, ): void { const imageByPath = buildImageByPath(snapshot.images); for (const entry of snapshot.termEntries) { const term = normalizeLookupTerm(entry[0]); if (!term || index.has(term)) { continue; } const image = createCharacterNameImage(entry, imageByPath); if (image) { index.set(term, image); } } } export function snapshotHasCharacterNameImages(snapshot: CharacterDictionarySnapshot): boolean { const imageByPath = buildImageByPath(snapshot.images); return snapshot.termEntries.some( (entry) => createCharacterNameImage(entry, imageByPath) !== null, ); } function getSnapshotDirectorySignature(outputDir: string): string { let entries: fs.Dirent[] = []; try { entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true }); } catch { return ''; } const parts: string[] = []; for (const entry of entries) { if (!entry.isFile() || !/^anilist-\d+\.json$/.test(entry.name)) { continue; } const snapshotPath = path.join(getSnapshotsDir(outputDir), entry.name); try { const stat = fs.statSync(snapshotPath); parts.push(`${entry.name}:${stat.mtimeMs}:${stat.size}`); } catch { // Ignore files that disappear during refresh; next lookup will rebuild. } } return parts.sort().join('|'); } export function buildCharacterNameImageIndexFromSnapshots( outputDir: string, ): Map { const index = new Map(); for (const snapshot of readCachedSnapshots(outputDir)) { appendSnapshotImages(index, snapshot); } return index; } export function createCharacterDictionaryImageLookup(deps: { userDataPath?: string; outputDir?: string; getCurrentMediaId?: () => number | null | undefined; }): { get: (term: string, mediaId?: number | null) => CharacterNameImage | null; invalidate: () => void; } { const outputDir = deps.outputDir ?? (deps.userDataPath ? path.join(deps.userDataPath, 'character-dictionaries') : ''); let signature: string | null = null; let index = new Map(); let indexByMediaId = new Map>(); function refreshIfNeeded(): void { if (!outputDir) { index = new Map(); indexByMediaId = new Map>(); signature = ''; return; } const nextSignature = getSnapshotDirectorySignature(outputDir); if (nextSignature === signature) { return; } signature = nextSignature; index = new Map(); indexByMediaId = new Map>(); for (const snapshot of readCachedSnapshots(outputDir)) { appendSnapshotImages(index, snapshot); const mediaIndex = new Map(); appendSnapshotImages(mediaIndex, snapshot); if (mediaIndex.size > 0) { indexByMediaId.set(snapshot.mediaId, mediaIndex); } } } return { get(term: string, mediaId?: number | null): CharacterNameImage | null { const normalizedTerm = normalizeLookupTerm(term); if (!normalizedTerm) { return null; } refreshIfNeeded(); const scopedMediaId = normalizeLookupMediaId(mediaId ?? deps.getCurrentMediaId?.() ?? null); if (scopedMediaId !== null) { return indexByMediaId.get(scopedMediaId)?.get(normalizedTerm) ?? null; } return index.get(normalizedTerm) ?? null; }, invalidate(): void { signature = null; }, }; }