From 7a0e66fe219fef76020f67b9d59222f927375bd7 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:51:19 -0600 Subject: [PATCH] feat(metadata): add CBX/CBZ format writers and ComicInfo support --- .../features/settings/MaintenanceSettings.vue | 242 ++++++++++++++---- packages/types/src/file-write.ts | 19 +- server/src/common/logger.config.ts | 22 +- .../app-settings/app-settings.service.ts | 16 +- server/src/modules/cbz/cbz.service.ts | 40 ++- .../file-write/file-write-settings.service.ts | 15 +- .../modules/file-write/file-write.module.ts | 8 +- .../modules/file-write/file-write.service.ts | 34 ++- .../formats/cbx/cb7-format-writer.ts | 121 +++++++++ .../formats/cbx/cbz-format-writer.ts | 30 +++ .../file-write/formats/cbx/cbz-zip-patcher.ts | 50 ++++ .../formats/cbx/comic-info-builder.ts | 122 +++++++++ 12 files changed, 646 insertions(+), 73 deletions(-) create mode 100644 server/src/modules/file-write/formats/cbx/cb7-format-writer.ts create mode 100644 server/src/modules/file-write/formats/cbx/cbz-format-writer.ts create mode 100644 server/src/modules/file-write/formats/cbx/cbz-zip-patcher.ts create mode 100644 server/src/modules/file-write/formats/cbx/comic-info-builder.ts diff --git a/client/src/features/settings/MaintenanceSettings.vue b/client/src/features/settings/MaintenanceSettings.vue index 1a00e711e..bf8379033 100644 --- a/client/src/features/settings/MaintenanceSettings.vue +++ b/client/src/features/settings/MaintenanceSettings.vue @@ -26,22 +26,33 @@ async function rebuildEmbeddings() { } } -const writeSettings = ref({ ...DEFAULT_FILE_WRITE_SETTINGS }) +const writeSettings = ref(structuredClone(DEFAULT_FILE_WRITE_SETTINGS)) const writeSaving = ref(false) const writeSaved = ref(false) -const maxFileSizeMb = computed({ - get: () => Math.round(writeSettings.value.maxFileSizeBytes / (1024 * 1024)), +const epubMaxMb = computed({ + get: () => Math.round(writeSettings.value.epub.maxFileSizeBytes / (1024 * 1024)), set: (mb: number) => { - writeSettings.value.maxFileSizeBytes = mb * 1024 * 1024 + writeSettings.value.epub.maxFileSizeBytes = mb * 1024 * 1024 + }, +}) +const pdfMaxMb = computed({ + get: () => Math.round(writeSettings.value.pdf.maxFileSizeBytes / (1024 * 1024)), + set: (mb: number) => { + writeSettings.value.pdf.maxFileSizeBytes = mb * 1024 * 1024 + }, +}) +const cbxMaxMb = computed({ + get: () => Math.round(writeSettings.value.cbx.maxFileSizeBytes / (1024 * 1024)), + set: (mb: number) => { + writeSettings.value.cbx.maxFileSizeBytes = mb * 1024 * 1024 }, }) onMounted(async () => { const res = await api('/api/v1/app-settings/file-write-settings') if (res.ok) { - const data: GlobalFileWriteSettings = await res.json() - writeSettings.value = data + writeSettings.value = await res.json() } }) @@ -67,13 +78,19 @@ async function saveWriteSettings() { } } -function toggleEnabled() { - writeSettings.value.enabled = !writeSettings.value.enabled +function toggle(path: () => boolean, set: (v: boolean) => void) { + set(!path()) void saveWriteSettings() } -function toggleWriteCover() { - writeSettings.value.writeCover = !writeSettings.value.writeCover +function toggleCbxFormat(fmt: 'cbz' | 'cb7') { + const formats = writeSettings.value.cbx.formats + const idx = formats.indexOf(fmt) + if (idx === -1) { + writeSettings.value.cbx.formats = [...formats, fmt] + } else { + writeSettings.value.cbx.formats = formats.filter((f) => f !== fmt) + } void saveWriteSettings() } @@ -118,22 +135,30 @@ function toggleWriteCover() {

Metadata File Sync

-
-
-
- -
-
-

Write metadata to original files

-

- When enabled, saving metadata automatically updates the physical file on disk. Supports EPUB. Other formats are skipped. -

+
+ +
+
+
+ +
+
+

Write metadata to original files

+

+ When enabled, saving metadata updates the physical file on disk. Configure each format below. +

+
-
-
+ + +
Settings saved -

+
diff --git a/packages/types/src/file-write.ts b/packages/types/src/file-write.ts index 8dbb25b3c..bdf86cc67 100644 --- a/packages/types/src/file-write.ts +++ b/packages/types/src/file-write.ts @@ -20,15 +20,30 @@ export type WriteLogEntry = { errorMessage: string | null; }; -export interface GlobalFileWriteSettings { +export interface FormatWriteSettings { enabled: boolean; maxFileSizeBytes: number; +} + +export interface CbxFormatWriteSettings { + enabled: boolean; + maxFileSizeBytes: number; + formats: ('cbz' | 'cb7')[]; +} + +export interface GlobalFileWriteSettings { + enabled: boolean; writeCover: boolean; + epub: FormatWriteSettings; + pdf: FormatWriteSettings; + cbx: CbxFormatWriteSettings; } export const DEFAULT_FILE_WRITE_SETTINGS: GlobalFileWriteSettings = { enabled: false, - maxFileSizeBytes: 104_857_600, writeCover: true, + epub: { enabled: true, maxFileSizeBytes: 104_857_600 }, + pdf: { enabled: true, maxFileSizeBytes: 104_857_600 }, + cbx: { enabled: false, formats: ['cbz', 'cb7'], maxFileSizeBytes: 524_288_000 }, }; diff --git a/server/src/common/logger.config.ts b/server/src/common/logger.config.ts index 91183b635..e7ffb1de8 100644 --- a/server/src/common/logger.config.ts +++ b/server/src/common/logger.config.ts @@ -1,20 +1,21 @@ import type { Params } from 'nestjs-pino'; import type { IncomingMessage, ServerResponse } from 'http'; +import { RequestMethod } from '@nestjs/common'; const isDev = process.env.NODE_ENV !== 'production'; const FRAMEWORK_CONTEXTS = new Set(['InstanceLoader', 'RouterExplorer', 'RoutesResolver']); -const SILENT_URL_RE = /\/books\/\d+\/(thumbnail|cover)(\?|$)/; - export const loggerConfig: Params = { + exclude: [ + { method: RequestMethod.GET, path: 'books/:id/thumbnail' }, + { method: RequestMethod.GET, path: 'books/:id/cover' }, + { method: RequestMethod.GET, path: 'cbz/files/:id/pages/:page' }, + ], pinoHttp: { level: isDev ? 'debug' : 'info', - autoLogging: { - ignore: (req: IncomingMessage) => SILENT_URL_RE.test(req.url ?? ''), - }, hooks: { - logMethod(inputArgs, method) { + logMethod: function (inputArgs, method) { const first = inputArgs[0]; if ( first !== null && @@ -24,8 +25,7 @@ export const loggerConfig: Params = { ) { return; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return method.apply(this, inputArgs); + method.apply(this, inputArgs); }, }, customProps: () => ({ context: 'HTTP' }), @@ -41,10 +41,8 @@ export const loggerConfig: Params = { return 'info'; }, serializers: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - req: (req: any) => ({ id: req.id as string, method: req.method as string, url: req.url as string }), - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - res: (res: any) => ({ statusCode: res.statusCode as number }), + req: (req: IncomingMessage & { id?: string }) => ({ id: req.id, method: req.method, url: req.url }), + res: (res: ServerResponse) => ({ statusCode: res.statusCode }), }, ...(isDev ? { diff --git a/server/src/modules/app-settings/app-settings.service.ts b/server/src/modules/app-settings/app-settings.service.ts index 40f07dcc4..7a4c0d76a 100644 --- a/server/src/modules/app-settings/app-settings.service.ts +++ b/server/src/modules/app-settings/app-settings.service.ts @@ -149,12 +149,14 @@ export class AppSettingsService { const row = await this.db.query.appSettings.findFirst({ where: eq(schema.appSettings.key, APP_SETTING_KEYS.FILE_WRITE_SETTINGS), }); - return parseSafe(row?.value, { ...DEFAULT_FILE_WRITE_SETTINGS }); + if (!row?.value) return { ...DEFAULT_FILE_WRITE_SETTINGS }; + const stored = parseSafe>(row.value, {}); + return mergeFileWriteSettings(DEFAULT_FILE_WRITE_SETTINGS, stored); } async updateFileWriteSettings(patch: Partial): Promise { const current = await this.getFileWriteSettings(); - const merged: GlobalFileWriteSettings = { ...current, ...patch }; + const merged = mergeFileWriteSettings(current, patch); const value = JSON.stringify(merged); await this.db .insert(schema.appSettings) @@ -163,3 +165,13 @@ export class AppSettingsService { return merged; } } + +function mergeFileWriteSettings(base: GlobalFileWriteSettings, patch: Partial): GlobalFileWriteSettings { + return { + ...base, + ...patch, + epub: { ...base.epub, ...(patch.epub ?? {}) }, + pdf: { ...base.pdf, ...(patch.pdf ?? {}) }, + cbx: { ...base.cbx, ...(patch.cbx ?? {}) }, + }; +} diff --git a/server/src/modules/cbz/cbz.service.ts b/server/src/modules/cbz/cbz.service.ts index abc25996e..aa528f80e 100644 --- a/server/src/modules/cbz/cbz.service.ts +++ b/server/src/modules/cbz/cbz.service.ts @@ -36,6 +36,8 @@ function mimeForExt(name: string): string { // ── CBZ: ZIP byte-offset scan ────────────────────────────────────────────────── const LFH_SIG = Buffer.from([0x50, 0x4b, 0x03, 0x04]); +// Data descriptor signature (optional but written by most tools) +const DD_SIG = Buffer.from([0x50, 0x4b, 0x07, 0x08]); interface ZipEntry { name: string; @@ -46,6 +48,11 @@ interface ZipEntry { // Scans local file headers directly — bypasses the central directory, which // unzipper fails to locate when the ZIP has a large comment (e.g. ComicTagger metadata). +// +// When general purpose bit flag bit 3 is set the local header stores +// compressedSize=0 and the real size is in a data descriptor after the data. +// We resolve that by scanning for the descriptor signature and verifying the +// size field is self-consistent (dataStart + cSize === descriptorOffset). function buildZipIndex(buf: Buffer): ZipEntry[] { const entries: ZipEntry[] = []; let pos = 0; @@ -54,27 +61,49 @@ function buildZipIndex(buf: Buffer): ZipEntry[] { const sigPos = buf.indexOf(LFH_SIG, pos); if (sigPos === -1) break; + const flags = buf.readUInt16LE(sigPos + 6); const compression = buf.readUInt16LE(sigPos + 8); - const compressedSize = buf.readUInt32LE(sigPos + 18); + let compressedSize = buf.readUInt32LE(sigPos + 18); const fileNameLen = buf.readUInt16LE(sigPos + 26); const extraLen = buf.readUInt16LE(sigPos + 28); const dataStart = sigPos + 30 + fileNameLen + extraLen; const fileName = buf.subarray(sigPos + 30, sigPos + 30 + fileNameLen).toString('utf-8'); + // Bit 3: data descriptor present — local header has size=0. + if ((flags & 0x0008) !== 0 && compressedSize === 0) { + compressedSize = resolveDataDescriptorSize(buf, dataStart); + } + if (!fileName.endsWith('/') && !isHidden(fileName) && isImage(fileName)) { - if (compression === 0 || compression === 8) { + // Only add entries whose size we were able to resolve. + if ((compression === 0 || compression === 8) && compressedSize > 0) { entries.push({ name: fileName, dataStart, compressedSize, compression }); } } - pos = dataStart + compressedSize; - if (compressedSize === 0) pos = sigPos + 4; + pos = compressedSize > 0 ? dataStart + compressedSize : sigPos + 4; } entries.sort((a, b) => naturalSort(a.name, b.name)); return entries; } +// Scans forward from dataStart for the data descriptor signature PK\x07\x08. +// Verifies self-consistency: the compressed-size field stored in the descriptor +// must equal (descriptorOffset - dataStart). This eliminates false positives +// where the compressed payload itself happens to contain the signature bytes. +function resolveDataDescriptorSize(buf: Buffer, dataStart: number): number { + let search = dataStart; + while (search < buf.length - 16) { + const idx = buf.indexOf(DD_SIG, search); + if (idx === -1) break; + const cSize = buf.readUInt32LE(idx + 8); + if (idx === dataStart + cSize) return cSize; + search = idx + 1; + } + return 0; +} + // ── CBR: node-unrar-js ───────────────────────────────────────────────────────── // Buffer.buffer is a shared pool; slice out only the bytes for this buffer. @@ -200,6 +229,9 @@ export class CbzService { throw new NotFoundException(`Page ${pageIndex} out of range`); } const entry = entries[pageIndex]; + if (entry.compressedSize === 0) { + throw new NotFoundException(`Page ${pageIndex} data could not be located in archive`); + } const raw = createReadStream(file.absolutePath, { start: entry.dataStart, end: entry.dataStart + entry.compressedSize - 1, diff --git a/server/src/modules/file-write/file-write-settings.service.ts b/server/src/modules/file-write/file-write-settings.service.ts index 4b4da8fad..c28a900fa 100644 --- a/server/src/modules/file-write/file-write-settings.service.ts +++ b/server/src/modules/file-write/file-write-settings.service.ts @@ -27,7 +27,8 @@ export class FileWriteSettingsService { }); if (!row) return { ...DEFAULT_FILE_WRITE_SETTINGS }; try { - return { ...DEFAULT_FILE_WRITE_SETTINGS, ...(JSON.parse(row.value) as Partial) }; + const stored = JSON.parse(row.value) as Partial; + return mergeSettings(DEFAULT_FILE_WRITE_SETTINGS, stored); } catch { return { ...DEFAULT_FILE_WRITE_SETTINGS }; } @@ -35,7 +36,7 @@ export class FileWriteSettingsService { async updateGlobal(patch: Partial): Promise { const current = await this.getGlobal(); - const merged: GlobalFileWriteSettings = { ...current, ...patch }; + const merged = mergeSettings(current, patch); const value = JSON.stringify(merged); await this.db .insert(schema.appSettings) @@ -44,3 +45,13 @@ export class FileWriteSettingsService { return merged; } } + +function mergeSettings(base: GlobalFileWriteSettings, patch: Partial): GlobalFileWriteSettings { + return { + ...base, + ...patch, + epub: { ...base.epub, ...(patch.epub ?? {}) }, + pdf: { ...base.pdf, ...(patch.pdf ?? {}) }, + cbx: { ...base.cbx, ...(patch.cbx ?? {}) }, + }; +} diff --git a/server/src/modules/file-write/file-write.module.ts b/server/src/modules/file-write/file-write.module.ts index e4a49ec59..ae87806d3 100644 --- a/server/src/modules/file-write/file-write.module.ts +++ b/server/src/modules/file-write/file-write.module.ts @@ -8,6 +8,8 @@ import { FormatWriterRegistry } from './format-writer.registry'; import { FORMAT_WRITERS } from './interfaces/format-writer.interface'; import { EpubFormatWriter } from './formats/epub/epub-format-writer'; import { PdfFormatWriter } from './formats/pdf/pdf-format-writer'; +import { CbzFormatWriter } from './formats/cbx/cbz-format-writer'; +import { Cb7FormatWriter } from './formats/cbx/cb7-format-writer'; @Module({ providers: [ @@ -17,10 +19,12 @@ import { PdfFormatWriter } from './formats/pdf/pdf-format-writer'; FileLockService, EpubFormatWriter, PdfFormatWriter, + CbzFormatWriter, + Cb7FormatWriter, { provide: FORMAT_WRITERS, - useFactory: (epub: EpubFormatWriter, pdf: PdfFormatWriter) => [epub, pdf], - inject: [EpubFormatWriter, PdfFormatWriter], + useFactory: (epub: EpubFormatWriter, pdf: PdfFormatWriter, cbz: CbzFormatWriter, cb7: Cb7FormatWriter) => [epub, pdf, cbz, cb7], + inject: [EpubFormatWriter, PdfFormatWriter, CbzFormatWriter, Cb7FormatWriter], }, FormatWriterRegistry, ], diff --git a/server/src/modules/file-write/file-write.service.ts b/server/src/modules/file-write/file-write.service.ts index fc8c1101a..b2023a4d8 100644 --- a/server/src/modules/file-write/file-write.service.ts +++ b/server/src/modules/file-write/file-write.service.ts @@ -3,14 +3,13 @@ import { ConfigService } from '@nestjs/config'; import { readdir, readFile } from 'fs/promises'; import { join } from 'path'; -import type { WriteResult } from '@projectx/types'; +import type { WriteResult, GlobalFileWriteSettings } from '@projectx/types'; import { FileLockService } from './file-lock.service'; import { FileWriteRepository } from './file-write.repository'; import { FileWriteSettingsService } from './file-write-settings.service'; import { FormatWriterRegistry } from './format-writer.registry'; import type { BookWritePayload, BookWritePayloadKey } from './interfaces/book-write-payload.interface'; -// All known payload fields for Phase 1 full-write mask const ALL_FIELDS = new Set([ 'title', 'subtitle', @@ -91,10 +90,20 @@ export class FileWriteService implements OnModuleDestroy { return { status: 'skipped', fieldsWritten: [], durationMs: 0, reason: 'disabled' }; } + const formatSettings = resolveFormatSettings(settings, format); + if (!formatSettings.enabled) { + const result: WriteResult = { status: 'skipped', fieldsWritten: [], durationMs: 0, reason: 'format disabled' }; + this.logger.debug(`Write skipped for book ${bookId}: format disabled (${format})`); + if (triggeredBy === 'sync') { + await this.fileWriteRepo.insertLog({ bookId, bookFileId: file.id, userId: userId ?? null, format, result, triggeredBy }); + } + return result; + } + const sizeBytes = file.sizeBytes ?? 0; - if (sizeBytes > settings.maxFileSizeBytes) { + if (sizeBytes > formatSettings.maxFileSizeBytes) { const result: WriteResult = { status: 'skipped', fieldsWritten: [], durationMs: 0, reason: 'file exceeds size limit' }; - this.logger.debug(`Write skipped for book ${bookId}: file size ${sizeBytes} exceeds limit ${settings.maxFileSizeBytes}`); + this.logger.debug(`Write skipped for book ${bookId}: file size ${sizeBytes} exceeds limit ${formatSettings.maxFileSizeBytes}`); if (triggeredBy === 'sync') { await this.fileWriteRepo.insertLog({ bookId, bookFileId: file.id, userId: userId ?? null, format, result, triggeredBy }); } @@ -144,3 +153,20 @@ export class FileWriteService implements OnModuleDestroy { } } } + +function resolveFormatSettings(settings: GlobalFileWriteSettings, format: string): { enabled: boolean; maxFileSizeBytes: number } { + switch (format) { + case 'epub': + return settings.epub; + case 'pdf': + return settings.pdf; + case 'cbz': + case 'cb7': + return { + enabled: settings.cbx.enabled && (settings.cbx.formats as string[]).includes(format), + maxFileSizeBytes: settings.cbx.maxFileSizeBytes, + }; + default: + return { enabled: false, maxFileSizeBytes: 0 }; + } +} diff --git a/server/src/modules/file-write/formats/cbx/cb7-format-writer.ts b/server/src/modules/file-write/formats/cbx/cb7-format-writer.ts new file mode 100644 index 000000000..c491ba438 --- /dev/null +++ b/server/src/modules/file-write/formats/cbx/cb7-format-writer.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@nestjs/common'; +import { readFile, rename, unlink, writeFile } from 'fs/promises'; +import { dirname, join } from 'path'; +import { randomUUID } from 'crypto'; + +import type { WriteResult } from '@projectx/types'; +import { getSevenZip, type SevenZipFS } from '../../../../common/sevenzip'; +import type { BookWritePayload } from '../../interfaces/book-write-payload.interface'; +import type { FormatWriter } from '../../interfaces/format-writer.interface'; +import type { FormatWriteOptions } from '../../interfaces/format-write-options.interface'; +import { resolveFieldsWritten } from '../shared/resolve-fields-written'; +import { buildComicInfoXml } from './comic-info-builder'; + +// All WASM FS ops are synchronous — Node's single-threaded event loop guarantees +// that synchronous blocks from concurrent writes never interleave with each other. +// The archive path uses a unique ID to avoid VFS collisions across concurrent writes. +// The XML path is shared but safe: it is only used within synchronous blocks and +// is always written before it is added to the archive, with no await in between. +const VFS_XML_PATH = '/ComicInfo.xml'; + +@Injectable() +export class Cb7FormatWriter implements FormatWriter { + readonly format = 'cb7'; + + async write(filePath: string, payload: BookWritePayload, options: FormatWriteOptions): Promise { + const start = Date.now(); + const { fieldMask, dryRun } = options; + const fieldsWritten = resolveFieldsWritten(payload, fieldMask); + + if (dryRun) { + return { status: 'skipped', reason: 'dry-run', fieldsWritten, durationMs: Date.now() - start }; + } + + const uid = randomUUID().replace(/-/g, ''); + const vfsArchivePath = `/cbx-arc-${uid}`; + const vfsExtractDir = `/cbx-ext-${uid}`; + + const sz = await getSevenZip(); + + try { + const archiveBytes = await readFile(filePath); + + // --- synchronous block start: no awaits until we're done with the VFS --- + + const fd = sz.FS.open(vfsArchivePath, 'w+'); + sz.FS.write(fd, archiveBytes, 0, archiveBytes.length); + sz.FS.close(fd); + + let existingXml: string | null = null; + try { + sz.FS.mkdir(vfsExtractDir); + sz.callMain(['e', vfsArchivePath, `-o${vfsExtractDir}`, 'ComicInfo.xml', '-y']); + const raw = sz.FS.readFile(`${vfsExtractDir}/ComicInfo.xml`); + existingXml = Buffer.from(raw).toString('utf-8'); + } catch { + // No ComicInfo.xml in archive — start fresh + } + + const xml = buildComicInfoXml(existingXml, payload, fieldMask); + const xmlBytes = Buffer.from(xml, 'utf-8'); + + try { + sz.FS.unlink(VFS_XML_PATH); + } catch { + // Not present — expected on first write + } + const fd2 = sz.FS.open(VFS_XML_PATH, 'w+'); + sz.FS.write(fd2, xmlBytes, 0, xmlBytes.length); + sz.FS.close(fd2); + + // Delete old entry (if any) then add the updated one. + // 7z strips the leading / from absolute VFS paths, so /ComicInfo.xml + // is stored in the archive as ComicInfo.xml at the root. + sz.callMain(['d', vfsArchivePath, 'ComicInfo.xml', '-y']); + sz.callMain(['a', vfsArchivePath, VFS_XML_PATH]); + + const modifiedBytes = sz.FS.readFile(vfsArchivePath); + + // --- synchronous block end --- + + const tmpPath = join(dirname(filePath), `.cbx-write-${uid}`); + await writeFile(tmpPath, Buffer.from(modifiedBytes)); + try { + await rename(tmpPath, filePath); + } catch (err) { + await unlink(tmpPath).catch(() => {}); + throw err; + } + + return { status: 'success', fieldsWritten, durationMs: Date.now() - start }; + } finally { + cleanupVfs(sz.FS, vfsArchivePath, vfsExtractDir); + } + } +} + +function cleanupVfs(vfs: SevenZipFS, archivePath: string, extractDir: string): void { + try { + vfs.unlink(VFS_XML_PATH); + } catch { + // Already deleted by a concurrent write's cleanup — that's fine + } + try { + vfs.unlink(archivePath); + } catch { + /* ignore */ + } + try { + for (const f of vfs.readdir(extractDir)) { + if (f === '.' || f === '..') continue; + try { + vfs.unlink(`${extractDir}/${f}`); + } catch { + /* ignore */ + } + } + vfs.rmdir(extractDir); + } catch { + /* ignore */ + } +} diff --git a/server/src/modules/file-write/formats/cbx/cbz-format-writer.ts b/server/src/modules/file-write/formats/cbx/cbz-format-writer.ts new file mode 100644 index 000000000..5183f6c5a --- /dev/null +++ b/server/src/modules/file-write/formats/cbx/cbz-format-writer.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; + +import type { WriteResult } from '@projectx/types'; +import type { BookWritePayload } from '../../interfaces/book-write-payload.interface'; +import type { FormatWriter } from '../../interfaces/format-writer.interface'; +import type { FormatWriteOptions } from '../../interfaces/format-write-options.interface'; +import { resolveFieldsWritten } from '../shared/resolve-fields-written'; +import { buildComicInfoXml } from './comic-info-builder'; +import { readComicInfoFromZip, writeComicInfoToZip } from './cbz-zip-patcher'; + +@Injectable() +export class CbzFormatWriter implements FormatWriter { + readonly format = 'cbz'; + + async write(filePath: string, payload: BookWritePayload, options: FormatWriteOptions): Promise { + const start = Date.now(); + const { fieldMask, dryRun } = options; + const fieldsWritten = resolveFieldsWritten(payload, fieldMask); + + if (dryRun) { + return { status: 'skipped', reason: 'dry-run', fieldsWritten, durationMs: Date.now() - start }; + } + + const existingXml = await readComicInfoFromZip(filePath); + const xml = buildComicInfoXml(existingXml, payload, fieldMask); + await writeComicInfoToZip(filePath, xml); + + return { status: 'success', fieldsWritten, durationMs: Date.now() - start }; + } +} diff --git a/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.ts b/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.ts new file mode 100644 index 000000000..b8fec78de --- /dev/null +++ b/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.ts @@ -0,0 +1,50 @@ +import * as fs from 'fs/promises'; +import { createWriteStream } from 'fs'; +import { dirname, join } from 'path'; +import { randomUUID } from 'crypto'; +import * as unzipper from 'unzipper'; +import archiver from 'archiver'; + +function isComicInfoEntry(entryPath: string): boolean { + const normalized = entryPath.replace(/\\/g, '/').toLowerCase(); + return normalized === 'comicinfo.xml' || normalized.endsWith('/comicinfo.xml'); +} + +export async function readComicInfoFromZip(filePath: string): Promise { + const zip = await unzipper.Open.file(filePath); + const entry = zip.files.find((f) => isComicInfoEntry(f.path)); + if (!entry) return null; + return (await entry.buffer()).toString('utf-8'); +} + +export async function writeComicInfoToZip(filePath: string, xmlContent: string): Promise { + const zip = await unzipper.Open.file(filePath); + const existing = zip.files.find((f) => isComicInfoEntry(f.path)); + const xmlEntryPath = existing?.path ?? 'ComicInfo.xml'; + + const tmpPath = join(dirname(filePath), `.cbx-write-${randomUUID()}`); + const archive = archiver('zip', { zlib: { level: 6 } }); + const output = createWriteStream(tmpPath); + + await new Promise((resolve, reject) => { + output.on('close', resolve); + output.on('error', reject); + archive.on('error', reject); + archive.pipe(output); + + for (const entry of zip.files) { + if (isComicInfoEntry(entry.path)) continue; + archive.append(entry.stream(), { name: entry.path }); + } + + archive.append(Buffer.from(xmlContent, 'utf-8'), { name: xmlEntryPath }); + void archive.finalize(); + }); + + try { + await fs.rename(tmpPath, filePath); + } catch (err) { + await fs.unlink(tmpPath).catch(() => {}); + throw err; + } +} diff --git a/server/src/modules/file-write/formats/cbx/comic-info-builder.ts b/server/src/modules/file-write/formats/cbx/comic-info-builder.ts new file mode 100644 index 000000000..d983916b7 --- /dev/null +++ b/server/src/modules/file-write/formats/cbx/comic-info-builder.ts @@ -0,0 +1,122 @@ +import { XMLParser, XMLBuilder } from 'fast-xml-parser'; + +import type { BookWritePayload, BookWritePayloadKey } from '../../interfaces/book-write-payload.interface'; + +type ComicInfoObject = Record; + +const PARSER = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + parseTagValue: true, + trimValues: true, +}); + +const BUILDER = new XMLBuilder({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + format: true, + indentBy: ' ', + suppressBooleanAttributes: false, +}); + +function parseComicInfoXml(xml: string): ComicInfoObject { + try { + const parsed = PARSER.parse(xml) as { ComicInfo?: ComicInfoObject }; + return parsed.ComicInfo ?? {}; + } catch { + return {}; + } +} + +export function buildComicInfoXml(existingXml: string | null, payload: BookWritePayload, fieldMask: Set): string { + const info: ComicInfoObject = existingXml ? parseComicInfoXml(existingXml) : {}; + + if (fieldMask.has('title') && payload.title != null) info['Title'] = payload.title; + if (fieldMask.has('description') && payload.description != null) info['Summary'] = stripHtml(payload.description); + if (fieldMask.has('publisher') && payload.publisher != null) info['Publisher'] = payload.publisher; + if (fieldMask.has('seriesName') && payload.seriesName != null) info['Series'] = payload.seriesName; + if (fieldMask.has('seriesIndex') && payload.seriesIndex != null) info['Number'] = formatSeriesIndex(payload.seriesIndex); + if (fieldMask.has('publishedYear') && payload.publishedYear != null) info['Year'] = payload.publishedYear; + if (fieldMask.has('pageCount') && payload.pageCount != null) info['PageCount'] = payload.pageCount; + if (fieldMask.has('language') && payload.language != null) info['LanguageISO'] = payload.language; + if (fieldMask.has('authors') && payload.authors?.length) info['Writer'] = payload.authors.map((a) => a.name).join(', '); + if (fieldMask.has('genres') && payload.genres?.length) info['Genre'] = payload.genres.join(', '); + if (fieldMask.has('tags') && payload.tags?.length) info['Tags'] = payload.tags.join(', '); + if (fieldMask.has('rating') && payload.rating != null) info['CommunityRating'] = formatRating(payload.rating); + if (fieldMask.has('isbn13') && payload.isbn13 != null) info['GTIN'] = payload.isbn13; + + const webUrl = resolveWebUrl(payload); + if (webUrl != null) info['Web'] = webUrl; + + const existingNotes = typeof info['Notes'] === 'string' ? info['Notes'] : null; + const notes = buildNotes(existingNotes, payload); + if (notes != null) { + info['Notes'] = notes; + } else { + delete info['Notes']; + } + + if (!info['@_xmlns:xsi']) { + info['@_xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance'; + info['@_xmlns:xsd'] = 'http://www.w3.org/2001/XMLSchema'; + } + + const xmlBody = BUILDER.build({ ComicInfo: info }); + return `\n${xmlBody}`; +} + +function stripHtml(html: string): string { + return html + .replace(/<[^>]*>/g, ' ') + .replace(/\s{2,}/g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .trim(); +} + +function formatSeriesIndex(val: number): string { + return val % 1 === 0 ? String(Math.trunc(val)) : String(val); +} + +function formatRating(val: number): string { + return Math.min(5.0, Math.max(0.0, val / 2.0)).toFixed(1); +} + +function resolveWebUrl(payload: BookWritePayload): string | null { + if (payload.goodreadsId) return `https://www.goodreads.com/book/show/${payload.goodreadsId}`; + if (payload.amazonId) return `https://www.amazon.com/dp/${payload.amazonId}`; + if (payload.hardcoverId) return `https://hardcover.app/books/${payload.hardcoverId}`; + if (payload.googleBooksId) return `https://books.google.com/books?id=${payload.googleBooksId}`; + if (payload.openLibraryId) return `https://openlibrary.org/works/${payload.openLibraryId}`; + return null; +} + +function buildNotes(existing: string | null, payload: BookWritePayload): string | null { + const lines: string[] = []; + + if (existing) { + for (const line of existing.split('\n')) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('[projectx:')) lines.push(trimmed); + } + } + + const ids: [string, string | null | undefined][] = [ + ['subtitle', payload.subtitle], + ['isbn10', payload.isbn10], + ['goodreadsId', payload.goodreadsId], + ['amazonId', payload.amazonId], + ['hardcoverId', payload.hardcoverId], + ['googleBooksId', payload.googleBooksId], + ['openLibraryId', payload.openLibraryId], + ]; + + for (const [key, val] of ids) { + if (val != null && val !== '') lines.push(`[projectx:${key}] ${val}`); + } + + return lines.length > 0 ? lines.join('\n') : null; +}