feat: add file-write module for writing metadata back to EPUB files

This commit is contained in:
neon
2026-03-03 04:56:16 -06:00
parent d63859cf30
commit 6d76243909
36 changed files with 1843 additions and 45 deletions
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, Download, Files } from 'lucide-vue-next'
import type { BookDetail, BookDetailFile } from '@projectx/types'
import { BookOpen, Download, Files, History } from 'lucide-vue-next'
import type { BookDetail, BookDetailFile, WriteLogEntry } from '@projectx/types'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { api } from '@/lib/api'
const props = defineProps<{ book: BookDetail }>()
const router = useRouter()
@@ -21,6 +23,18 @@ function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const s = Math.floor(diff / 1000)
if (s < 60) return `${s}s ago`
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
const d = Math.floor(h / 24)
return `${d}d ago`
}
function openFile(file: BookDetailFile) {
router.push({
name: 'reader',
@@ -73,10 +87,72 @@ function fileIconText(format: string | null): string {
return 'text-muted-foreground'
}
}
const writeLogOpen = ref(false)
const writeLog = ref<WriteLogEntry[]>([])
const writeLogLoading = ref(false)
watch(
() => props.book.id,
() => {
writeLogOpen.value = false
writeLog.value = []
},
)
async function toggleWriteLog() {
if (writeLogOpen.value) {
writeLogOpen.value = false
return
}
writeLogOpen.value = true
if (writeLog.value.length > 0) return
writeLogLoading.value = true
try {
const res = await api(`/api/v1/books/${props.book.id}/write-log`)
if (res.ok) {
const data: { entries: WriteLogEntry[] } = await res.json()
writeLog.value = data.entries
}
} finally {
writeLogLoading.value = false
}
}
</script>
<template>
<div class="max-w-6xl space-y-2">
<!-- lastWrittenAt info strip -->
<div v-if="book.lastWrittenAt" class="flex items-center justify-between px-1 py-1">
<span class="text-xs text-muted-foreground"> Last synced to file: {{ formatRelative(book.lastWrittenAt) }} </span>
<button class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" @click="toggleWriteLog">
<History class="size-3" />
{{ writeLogOpen ? 'Hide log' : 'View sync log' }}
</button>
</div>
<!-- Inline sync log -->
<div v-if="writeLogOpen" class="rounded-lg border border-border bg-muted/30 px-4 py-3 space-y-1.5">
<p v-if="writeLogLoading" class="text-xs text-muted-foreground">Loading...</p>
<p v-else-if="writeLog.length === 0" class="text-xs text-muted-foreground">No write history yet.</p>
<div v-for="entry in writeLog" :key="entry.id" class="flex items-center gap-2 text-xs">
<span
class="shrink-0 font-medium"
:class="{
'text-green-600 dark:text-green-400': entry.status === 'success',
'text-destructive': entry.status === 'failed',
'text-muted-foreground': entry.status === 'skipped',
}"
>{{ entry.status }}</span
>
<span class="text-muted-foreground">{{ formatRelative(entry.writtenAt) }}</span>
<span class="text-muted-foreground font-mono uppercase">{{ entry.format }}</span>
<span v-if="entry.status === 'failed' && entry.errorMessage" class="text-destructive truncate">{{ entry.errorMessage }}</span>
<span v-else-if="entry.fieldsWritten.length" class="text-muted-foreground truncate">{{ entry.fieldsWritten.length }} fields</span>
</div>
</div>
<!-- File list -->
<div
v-for="file in book.files"
:key="file.id"
@@ -0,0 +1,66 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { LibraryFileSyncProgressEvent } from '@projectx/types'
export interface FileSyncStats {
processed: number
succeeded: number
failed: number
skipped: number
}
export function useLibraryFileSync() {
const syncing = ref(false)
const stats = ref<FileSyncStats | null>(null)
const error = ref<string | null>(null)
async function syncAll(libraryId: number, dryRun = false): Promise<void> {
syncing.value = true
stats.value = null
error.value = null
try {
const url = `/api/v1/libraries/${libraryId}/write-metadata-to-files${dryRun ? '?dryRun=true' : ''}`
const res = await api(url, { method: 'POST' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
if (!res.body) throw new Error('No response body')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const raw = line.slice(6).trim()
if (!raw) continue
const event = JSON.parse(raw) as LibraryFileSyncProgressEvent
if ('done' in event && event.done) {
stats.value = {
processed: event.processed,
succeeded: event.succeeded,
failed: event.failed,
skipped: event.skipped,
}
}
}
}
} catch (e) {
reader.cancel().catch(() => {})
throw e
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Sync failed'
} finally {
syncing.value = false
}
}
return { syncing, stats, error, syncAll }
}
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { FolderOpen, Plus, RefreshCw, Pencil, Trash2, Images } from 'lucide-vue-next'
import { FolderOpen, Plus, RefreshCw, Pencil, Trash2, Images, FileEdit } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import type { Library as LibraryType, LibraryStats } from '@projectx/types'
import type { GlobalFileWriteSettings, Library as LibraryType, LibraryStats } from '@projectx/types'
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useLibraryFileSync } from '@/features/library/composables/useLibraryFileSync'
import { useScanProgress, getSocket } from '@/features/scanner/composables/useScanProgress'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -23,6 +24,36 @@ const pendingNavigateLibraryId = ref<number | null>(null)
const deletingLibrary = ref<LibraryType | null>(null)
const deleteConfirmName = ref('')
const deleting = ref(false)
const fileSyncingMap = ref<Record<number, boolean>>({})
const fileWriteEnabled = ref(false)
const confirmSyncLibrary = ref<LibraryType | null>(null)
const { syncAll: syncAllFiles } = useLibraryFileSync()
function promptSyncFiles(lib: LibraryType) {
confirmSyncLibrary.value = lib
}
async function confirmSyncFiles() {
const lib = confirmSyncLibrary.value
if (!lib) return
confirmSyncLibrary.value = null
fileSyncingMap.value[lib.id] = true
try {
await syncAllFiles(lib.id)
toast.success(`Metadata synced to files for "${lib.name}"`)
} catch (e) {
const msg = e instanceof Error ? e.message : ''
if (msg.includes('400')) {
toast.error('Metadata file write is not enabled. Turn it on in Maintenance settings.')
} else {
toast.error(`File sync failed for "${lib.name}"`)
}
} finally {
fileSyncingMap.value[lib.id] = false
}
}
async function loadAllStats() {
await Promise.all(
@@ -44,6 +75,12 @@ onMounted(async () => {
await fetchLibraries()
subscribeAll()
loadAllStats()
api('/api/v1/app-settings/file-write-settings').then(async (res) => {
if (res.ok) {
const data: GlobalFileWriteSettings = await res.json()
fileWriteEnabled.value = data.enabled
}
})
})
const statsReloadedFor = new Set<number>()
@@ -294,6 +331,20 @@ function coverRefreshLabel(libraryId: number): string {
</TooltipTrigger>
<TooltipContent>Edit library</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="!!fileSyncingMap[lib.id] || !fileWriteEnabled"
@click="promptSyncFiles(lib)"
>
<FileEdit :size="13" :class="fileSyncingMap[lib.id] ? 'animate-pulse' : ''" />
</button>
</TooltipTrigger>
<TooltipContent>
{{ fileWriteEnabled ? 'Sync metadata to files' : 'Sync metadata to files (enable in Maintenance settings)' }}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
@@ -372,4 +423,30 @@ function coverRefreshLabel(libraryId: number): string {
</div>
</div>
</div>
<!-- Sync confirmation dialog -->
<div v-if="confirmSyncLibrary" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div class="bg-card border border-border rounded-xl shadow-xl w-full max-w-sm p-6">
<h3 class="text-base font-semibold text-foreground mb-1">Sync metadata to files?</h3>
<p class="text-sm text-muted-foreground mb-4">
This will overwrite the metadata inside every supported file in
<span class="font-medium text-foreground">{{ confirmSyncLibrary.name }}</span>
directly on disk. This cannot be undone.
</p>
<div class="flex justify-end gap-2">
<button
class="px-4 py-2 text-sm font-medium rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground"
@click="confirmSyncLibrary = null"
>
Cancel
</button>
<button
class="px-4 py-2 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
@click="confirmSyncFiles"
>
Sync files
</button>
</div>
</div>
</div>
</template>
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Check, RefreshCw, Sparkles } from 'lucide-vue-next'
import { ref, computed, onMounted } from 'vue'
import { Check, RefreshCw, Sparkles, FileEdit } from 'lucide-vue-next'
import type { GlobalFileWriteSettings } from '@projectx/types'
import { DEFAULT_FILE_WRITE_SETTINGS } from '@projectx/types'
import { api } from '@/lib/api'
@@ -23,43 +25,163 @@ async function rebuildEmbeddings() {
running.value = false
}
}
const writeSettings = ref<GlobalFileWriteSettings>({ ...DEFAULT_FILE_WRITE_SETTINGS })
const writeSaving = ref(false)
const writeSaved = ref(false)
const maxFileSizeMb = computed({
get: () => Math.round(writeSettings.value.maxFileSizeBytes / (1024 * 1024)),
set: (mb: number) => {
writeSettings.value.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
}
})
async function saveWriteSettings() {
if (writeSaving.value) return
writeSaving.value = true
writeSaved.value = false
try {
const res = await api('/api/v1/app-settings/file-write-settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(writeSettings.value),
})
if (res.ok) {
writeSettings.value = await res.json()
writeSaved.value = true
setTimeout(() => {
writeSaved.value = false
}, 2000)
}
} finally {
writeSaving.value = false
}
}
function toggleEnabled() {
writeSettings.value.enabled = !writeSettings.value.enabled
void saveWriteSettings()
}
function toggleWriteCover() {
writeSettings.value.writeCover = !writeSettings.value.writeCover
void saveWriteSettings()
}
</script>
<template>
<div class="mb-8">
<h2 class="settings-title">Maintenance</h2>
<p class="settings-subtitle">System maintenance and data operations.</p>
</div>
<div class="mb-8">
<h2 class="settings-title">Maintenance</h2>
<p class="settings-subtitle">System maintenance and data operations.</p>
</div>
<div>
<p class="settings-group-label">Recommendations</p>
<div class="border border-border rounded-lg bg-card px-5 py-5">
<div class="flex items-start justify-between gap-6">
<div class="flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Sparkles :size="16" class="text-primary" />
</div>
<div>
<p class="settings-label">Rebuild recommendation embeddings</p>
<p class="settings-hint leading-relaxed max-w-sm">
Generates vector embeddings for all books. Run this after a large import or if recommendations seem off. Processes in the background.
</p>
<p v-if="queued !== null" class="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 mt-2">
<Check :size="12" />
{{ queued }} books queued for processing
</p>
<p v-if="error" class="text-xs text-destructive mt-2">{{ error }}</p>
</div>
<div>
<p class="settings-group-label">Recommendations</p>
<div class="border border-border rounded-lg bg-card px-5 py-5">
<div class="flex items-start justify-between gap-6">
<div class="flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Sparkles :size="16" class="text-primary" />
</div>
<div>
<p class="settings-label">Rebuild recommendation embeddings</p>
<p class="settings-hint leading-relaxed max-w-sm">
Generates vector embeddings for all books. Run this after a large import or if recommendations seem off. Processes in the background.
</p>
<p v-if="queued !== null" class="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 mt-2">
<Check :size="12" />
{{ queued }} books queued for processing
</p>
<p v-if="error" class="text-xs text-destructive mt-2">{{ error }}</p>
</div>
</div>
<button
class="flex shrink-0 items-center gap-2 h-8 px-3 rounded-md border border-border bg-background text-sm text-foreground hover:bg-muted transition-colors disabled:opacity-50"
:disabled="running"
@click="rebuildEmbeddings"
>
<RefreshCw :size="13" :class="running ? 'animate-spin' : ''" />
{{ running ? 'Running...' : 'Run' }}
</button>
</div>
</div>
</div>
<div class="mt-8">
<p class="settings-group-label">Metadata File Sync</p>
<div class="border border-border rounded-lg bg-card px-5 py-5 space-y-5">
<div class="flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FileEdit :size="16" class="text-primary" />
</div>
<div class="flex-1">
<p class="settings-label">Write metadata to original files</p>
<p class="settings-hint leading-relaxed max-w-sm">
When enabled, saving metadata automatically updates the physical file on disk. Supports EPUB. Other formats are skipped.
</p>
</div>
<button
class="relative shrink-0 w-10 h-6 rounded-full transition-colors disabled:opacity-50"
:class="writeSettings.enabled ? 'bg-primary' : 'bg-muted'"
:disabled="writeSaving"
@click="toggleEnabled"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"
:class="writeSettings.enabled ? 'translate-x-4' : 'translate-x-0'"
/>
</button>
</div>
<div v-if="writeSettings.enabled" class="pl-12 space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<p class="settings-label text-sm">Max file size (MB)</p>
<p class="settings-hint text-xs">Files larger than this are skipped.</p>
</div>
<input
v-model.number="maxFileSizeMb"
type="number"
min="1"
max="2000"
:disabled="writeSaving"
class="w-24 text-sm border border-border rounded-md px-3 py-1.5 bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
@change="saveWriteSettings"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<p class="settings-label text-sm">Include cover image</p>
<p class="settings-hint text-xs">Writes the stored cover back into the file.</p>
</div>
<button
class="flex shrink-0 items-center gap-2 h-8 px-3 rounded-md border border-border bg-background text-sm text-foreground hover:bg-muted transition-colors disabled:opacity-50"
:disabled="running"
@click="rebuildEmbeddings"
class="relative shrink-0 w-10 h-6 rounded-full transition-colors disabled:opacity-50"
:class="writeSettings.writeCover ? 'bg-primary' : 'bg-muted'"
:disabled="writeSaving"
@click="toggleWriteCover"
>
<RefreshCw :size="13" :class="running ? 'animate-spin' : ''" />
{{ running ? 'Running...' : 'Run' }}
<span
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"
:class="writeSettings.writeCover ? 'translate-x-4' : 'translate-x-0'"
/>
</button>
</div>
</div>
<p v-if="writeSaved" class="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 pl-12">
<Check :size="12" />
Settings saved
</p>
</div>
</div>
</template>
+1
View File
@@ -61,6 +61,7 @@ export type BookDetail = {
genres: string[];
tags: string[];
files: BookDetailFile[];
lastWrittenAt: string | null;
};
export type BooksPage = {
+34
View File
@@ -0,0 +1,34 @@
export type WriteResult = {
status: 'success' | 'skipped' | 'failed';
fieldsWritten: string[];
durationMs: number;
reason?: string;
};
export type LibraryFileSyncProgressEvent =
| { bookId: number; status: 'success' | 'failed' | 'skipped'; reason?: string }
| { done: true; processed: number; succeeded: number; failed: number; skipped: number };
export type WriteLogEntry = {
id: number;
format: string;
status: string;
fieldsWritten: string[];
triggeredBy: string;
writtenAt: string;
durationMs: number | null;
errorMessage: string | null;
};
export interface GlobalFileWriteSettings {
enabled: boolean;
maxFileSizeBytes: number;
writeCover: boolean;
}
export const DEFAULT_FILE_WRITE_SETTINGS: GlobalFileWriteSettings = {
enabled: false,
maxFileSizeBytes: 104_857_600,
writeCover: true,
};
+1
View File
@@ -13,3 +13,4 @@ export * from "./scanner";
export * from "./opds";
export * from "./kobo";
export * from "./staging";
export * from "./file-write";
+5
View File
@@ -5,9 +5,14 @@ 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 = {
pinoHttp: {
level: isDev ? 'debug' : 'info',
autoLogging: {
ignore: (req: IncomingMessage) => SILENT_URL_RE.test(req.url ?? ''),
},
hooks: {
logMethod(inputArgs, method) {
const first = inputArgs[0];
@@ -222,6 +222,20 @@ CREATE TABLE "email_templates" (
CONSTRAINT "email_templates_user_id_name_unique" UNIQUE("user_id","name")
);
--> statement-breakpoint
CREATE TABLE "file_write_log" (
"id" serial PRIMARY KEY NOT NULL,
"book_id" integer NOT NULL,
"book_file_id" integer,
"user_id" integer,
"format" varchar(20) NOT NULL,
"status" varchar(10) NOT NULL,
"fields_written" jsonb,
"error_message" text,
"duration_ms" integer,
"triggered_by" varchar(10) NOT NULL,
"written_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "libraries" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
@@ -304,6 +318,7 @@ CREATE TABLE "book_metadata" (
"hardcover_id" varchar(50),
"open_library_id" varchar(50),
"embedding" vector(256),
"last_written_at" timestamp,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
@@ -531,6 +546,9 @@ ALTER TABLE "email_send_log" ADD CONSTRAINT "email_send_log_book_file_id_book_fi
ALTER TABLE "email_send_log" ADD CONSTRAINT "email_send_log_provider_id_email_providers_id_fk" FOREIGN KEY ("provider_id") REFERENCES "public"."email_providers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_send_log" ADD CONSTRAINT "email_send_log_template_id_email_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."email_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_templates" ADD CONSTRAINT "email_templates_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "file_write_log" ADD CONSTRAINT "file_write_log_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "file_write_log" ADD CONSTRAINT "file_write_log_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "file_write_log" ADD CONSTRAINT "file_write_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "library_folders" ADD CONSTRAINT "library_folders_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lenses" ADD CONSTRAINT "lenses_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_authors" ADD CONSTRAINT "book_authors_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
@@ -565,6 +583,7 @@ ALTER TABLE "staging_files" ADD CONSTRAINT "staging_files_target_folder_id_libra
CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "email_templates_system_name_unique" ON "email_templates" USING btree ("name") WHERE "email_templates"."user_id" is null;--> statement-breakpoint
CREATE INDEX "fwl_book_id_idx" ON "file_write_log" USING btree ("book_id");--> statement-breakpoint
CREATE INDEX "authors_name_trgm_idx" ON "authors" USING gin ("name" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "bm_title_trgm_idx" ON "book_metadata" USING gin ("title" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "bm_series_trgm_idx" ON "book_metadata" USING gin ("series_name" gin_trgm_ops);--> statement-breakpoint
@@ -1,5 +1,5 @@
{
"id": "d6367e5a-0f2b-4a49-9291-83caa87aa6f8",
"id": "147fbd21-53e8-47b8-ab41-2d093af68de3",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -1803,6 +1803,142 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.file_write_log": {
"name": "file_write_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"book_id": {
"name": "book_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"book_file_id": {
"name": "book_file_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"format": {
"name": "format",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
},
"fields_written": {
"name": "fields_written",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"triggered_by": {
"name": "triggered_by",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
},
"written_at": {
"name": "written_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"fwl_book_id_idx": {
"name": "fwl_book_id_idx",
"columns": [
{
"expression": "book_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"file_write_log_book_id_books_id_fk": {
"name": "file_write_log_book_id_books_id_fk",
"tableFrom": "file_write_log",
"tableTo": "books",
"columnsFrom": [
"book_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"file_write_log_book_file_id_book_files_id_fk": {
"name": "file_write_log_book_file_id_book_files_id_fk",
"tableFrom": "file_write_log",
"tableTo": "book_files",
"columnsFrom": [
"book_file_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"file_write_log_user_id_users_id_fk": {
"name": "file_write_log_user_id_users_id_fk",
"tableFrom": "file_write_log",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.libraries": {
"name": "libraries",
"schema": "",
@@ -2386,6 +2522,12 @@
"primaryKey": false,
"notNull": false
},
"last_written_at": {
"name": "last_written_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1772527273328,
"when": 1772569268748,
"tag": "0000_baseline",
"breakpoints": true
}
+27
View File
@@ -0,0 +1,27 @@
import { index, integer, jsonb, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { bookFiles, books } from './books';
import { users } from './auth';
export const fileWriteLog = pgTable(
'file_write_log',
{
id: serial('id').primaryKey(),
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
bookFileId: integer('book_file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
userId: integer('user_id').references(() => users.id, { onDelete: 'set null' }),
format: varchar('format', { length: 20 }).notNull(),
status: varchar('status', { length: 10 }).notNull(),
fieldsWritten: jsonb('fields_written').$type<string[]>(),
errorMessage: text('error_message'),
durationMs: integer('duration_ms'),
triggeredBy: varchar('triggered_by', { length: 10 }).notNull(),
writtenAt: timestamp('written_at').defaultNow().notNull(),
},
(t) => [index('fwl_book_id_idx').on(t.bookId)],
);
export type FileWriteLog = typeof fileWriteLog.$inferSelect;
export type NewFileWriteLog = typeof fileWriteLog.$inferInsert;
+1
View File
@@ -10,6 +10,7 @@ export * from './oidc';
export * from './opds';
export * from './kobo';
export * from './staging';
export * from './file-write';
export * from './email-templates';
export * from './email-providers';
export * from './email-recipients';
+1
View File
@@ -33,6 +33,7 @@ export const bookMetadata = pgTable(
hardcoverId: varchar('hardcover_id', { length: 50 }),
openLibraryId: varchar('open_library_id', { length: 50 }),
embedding: embedding256('embedding'),
lastWrittenAt: timestamp('last_written_at'),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(t) => [
@@ -1,5 +1,6 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
import type { GlobalFileWriteSettings } from '@projectx/types';
import { Public } from '../../common/decorators/public.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { AppSettingsService } from './app-settings.service';
@@ -77,4 +78,15 @@ export class AppSettingsController {
return { success: false, error: String(err) };
}
}
@Get('file-write-settings')
getFileWriteSettings() {
return this.appSettingsService.getFileWriteSettings();
}
@Put('file-write-settings')
@HttpCode(HttpStatus.OK)
updateFileWriteSettings(@Body() patch: Partial<GlobalFileWriteSettings>) {
return this.appSettingsService.updateFileWriteSettings(patch);
}
}
@@ -2,7 +2,7 @@ import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { eq, inArray } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DEFAULT_UPLOAD_PATTERN } from '@projectx/types';
import { DEFAULT_UPLOAD_PATTERN, DEFAULT_FILE_WRITE_SETTINGS, type GlobalFileWriteSettings } from '@projectx/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
@@ -15,6 +15,7 @@ const APP_SETTING_KEYS = {
STAGING_AUTO_FINALIZE_THRESHOLD: 'staging_auto_finalize_threshold',
STAGING_AUTO_FINALIZE_LIBRARY_ID: 'staging_auto_finalize_library_id',
STAGING_AUTO_FINALIZE_FOLDER_ID: 'staging_auto_finalize_folder_id',
FILE_WRITE_SETTINGS: 'file_write_settings',
} as const;
type Db = NodePgDatabase<typeof schema>;
@@ -143,4 +144,22 @@ export class AppSettingsService {
folderId: folderId && !isNaN(folderId) ? folderId : null,
};
}
async getFileWriteSettings(): Promise<GlobalFileWriteSettings> {
const row = await this.db.query.appSettings.findFirst({
where: eq(schema.appSettings.key, APP_SETTING_KEYS.FILE_WRITE_SETTINGS),
});
return parseSafe<GlobalFileWriteSettings>(row?.value, { ...DEFAULT_FILE_WRITE_SETTINGS });
}
async updateFileWriteSettings(patch: Partial<GlobalFileWriteSettings>): Promise<GlobalFileWriteSettings> {
const current = await this.getFileWriteSettings();
const merged: GlobalFileWriteSettings = { ...current, ...patch };
const value = JSON.stringify(merged);
await this.db
.insert(schema.appSettings)
.values({ key: APP_SETTING_KEYS.FILE_WRITE_SETTINGS, value })
.onConflictDoUpdate({ target: schema.appSettings.key, set: { value } });
return merged;
}
}
+15 -3
View File
@@ -21,6 +21,7 @@ import type { FastifyReply } from 'fastify';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { FileWriteRepository } from '../file-write/file-write.repository';
import { BookService } from './book.service';
import { BookQueryPipe } from './pipes/book-query.pipe';
import { BulkBookIdsDto } from './dto/bulk-book-ids.dto';
@@ -33,7 +34,10 @@ import type { BookQuery } from '@projectx/types';
@Controller('books')
export class BookController {
constructor(private readonly bookService: BookService) {}
constructor(
private readonly bookService: BookService,
private readonly fileWriteRepo: FileWriteRepository,
) {}
@Post('embed-all')
@RequirePermission('manage_app_settings')
@@ -65,7 +69,7 @@ export class BookController {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
Connection: 'keep-alive',
});
const result = await this.bookService.bulkRefreshMetadata(dto.bookIds, user, (bookId) => {
reply.raw.write(`data: ${JSON.stringify({ bookId })}\n\n`);
@@ -80,7 +84,7 @@ export class BookController {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
Connection: 'keep-alive',
});
const result = await this.bookService.bulkReExtractCover(dto.bookIds, user, (bookId) => {
reply.raw.write(`data: ${JSON.stringify({ bookId })}\n\n`);
@@ -213,6 +217,14 @@ export class BookController {
return this.bookService.refreshMetadata(id, preview === 'true', user);
}
@Get(':id/write-log')
@RequirePermission('library_edit_metadata')
async getWriteLog(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
await this.bookService.verifyBookAccess(id, user);
const entries = await this.fileWriteRepo.findWriteLog(id);
return { entries };
}
@Get(':id')
getDetail(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
return this.bookService.getDetail(id, user);
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module, forwardRef } from '@nestjs/common';
import { EmbeddingModule } from '../embedding/embedding.module';
import { FileWriteModule } from '../file-write/file-write.module';
import { LibraryModule } from '../library/library.module';
import { MetadataModule } from '../metadata/metadata.module';
import { MetadataFetchModule } from '../metadata-fetch/metadata-fetch.module';
@@ -10,7 +11,7 @@ import { BookRepository } from './book.repository';
import { BookService } from './book.service';
@Module({
imports: [forwardRef(() => LibraryModule), MetadataModule, EmbeddingModule, MetadataFetchModule],
imports: [forwardRef(() => LibraryModule), MetadataModule, EmbeddingModule, MetadataFetchModule, FileWriteModule],
controllers: [BookController],
providers: [BookService, BookRepository, BookQueryBuilder],
exports: [BookService, BookRepository, BookQueryBuilder],
+5 -1
View File
@@ -12,6 +12,7 @@ import { MetadataService } from '../metadata/metadata.service';
import { LibraryService } from '../library/library.service';
import { MetadataFetchPipeline, ResolvedMetadataFields } from '../metadata-fetch/metadata-fetch-pipeline';
import type { MetadataSearchParams } from '../metadata-fetch/providers/metadata-search-params';
import { FileWriteService } from '../file-write/file-write.service';
import { BookQueryBuilder } from './book-query-builder.service';
import { BookRepository } from './book.repository';
import { BookDetailDto } from './dto/book-detail.dto';
@@ -31,6 +32,7 @@ export class BookService {
private readonly pipeline: MetadataFetchPipeline,
private readonly config: ConfigService,
@Optional() private readonly embedder: BookEmbedderService,
@Optional() private readonly fileWriteService: FileWriteService,
) {
this.booksPath = this.config.get<string>('storage.booksPath')!;
}
@@ -39,7 +41,7 @@ export class BookService {
return user.roles.some((r) => r.isSuperuser);
}
private async verifyBookAccess(bookId: number, user: RequestUser): Promise<void> {
async verifyBookAccess(bookId: number, user: RequestUser): Promise<void> {
const libraryId = await this.bookRepo.findLibraryIdByBookId(bookId);
if (libraryId === null) throw new NotFoundException(`Book ${bookId} not found`);
await this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user));
@@ -179,6 +181,7 @@ export class BookService {
}
this.embedder?.embedBook(id).catch((err: Error) => this.logger.warn(`Embedding failed for book ${id}: ${err.message}`));
this.fileWriteService?.scheduleWrite(id, 'auto', user.id);
return this.getDetail(id, user);
}
@@ -389,6 +392,7 @@ export class BookService {
createdAt: f.createdAt,
filename: basename(f.absolutePath),
})),
lastWrittenAt: meta?.lastWrittenAt ?? null,
};
}
}
@@ -33,4 +33,5 @@ export class BookDetailDto {
genres: string[];
tags: string[];
files: BookFileDto[];
lastWrittenAt: Date | null;
}
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class FileLockService {
private readonly locks = new Map<string, Promise<void>>();
async withLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
const current = this.locks.get(path) ?? Promise.resolve();
let release!: () => void;
const unlock = new Promise<void>((resolve) => {
release = resolve;
});
const chain = current.then(() => unlock);
this.locks.set(path, chain);
try {
await current;
return await fn();
} finally {
release();
if (this.locks.get(path) === chain) {
this.locks.delete(path);
}
}
}
}
@@ -0,0 +1,46 @@
import { Inject, Injectable } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { GlobalFileWriteSettings } from '@projectx/types';
import { DEFAULT_FILE_WRITE_SETTINGS } from '@projectx/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
const FILE_WRITE_SETTINGS_KEY = 'file_write_settings';
type Db = NodePgDatabase<typeof schema>;
@Injectable()
export class FileWriteSettingsService {
constructor(@Inject(DB) private readonly db: Db) {}
// libraryId reserved for Phase 2 per-library overrides
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async resolve(_libraryId: number): Promise<GlobalFileWriteSettings> {
return this.getGlobal();
}
async getGlobal(): Promise<GlobalFileWriteSettings> {
const row = await this.db.query.appSettings.findFirst({
where: eq(schema.appSettings.key, FILE_WRITE_SETTINGS_KEY),
});
if (!row) return { ...DEFAULT_FILE_WRITE_SETTINGS };
try {
return { ...DEFAULT_FILE_WRITE_SETTINGS, ...(JSON.parse(row.value) as Partial<GlobalFileWriteSettings>) };
} catch {
return { ...DEFAULT_FILE_WRITE_SETTINGS };
}
}
async updateGlobal(patch: Partial<GlobalFileWriteSettings>): Promise<GlobalFileWriteSettings> {
const current = await this.getGlobal();
const merged: GlobalFileWriteSettings = { ...current, ...patch };
const value = JSON.stringify(merged);
await this.db
.insert(schema.appSettings)
.values({ key: FILE_WRITE_SETTINGS_KEY, value })
.onConflictDoUpdate({ target: schema.appSettings.key, set: { value } });
return merged;
}
}
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { FileLockService } from './file-lock.service';
import { FileWriteRepository } from './file-write.repository';
import { FileWriteService } from './file-write.service';
import { FileWriteSettingsService } from './file-write-settings.service';
import { FormatWriterRegistry } from './format-writer.registry';
import { FORMAT_WRITERS } from './interfaces/format-writer.interface';
import { EpubFormatWriter } from './formats/epub/epub-format-writer';
@Module({
providers: [
FileWriteService,
FileWriteRepository,
FileWriteSettingsService,
FileLockService,
EpubFormatWriter,
{
provide: FORMAT_WRITERS,
useFactory: (epub: EpubFormatWriter) => [epub],
inject: [EpubFormatWriter],
},
FormatWriterRegistry,
],
exports: [FileWriteService, FileWriteRepository, FileWriteSettingsService],
})
export class FileWriteModule {}
@@ -0,0 +1,128 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, desc, eq, ne } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { WriteResult, WriteLogEntry } from '@projectx/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookGenres, bookMetadata, books, fileWriteLog, genres, tags, bookTags } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
@Injectable()
export class FileWriteRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async findPrimaryFileForBook(bookId: number) {
const [row] = await this.db
.select({
id: bookFiles.id,
absolutePath: bookFiles.absolutePath,
format: bookFiles.format,
sizeBytes: bookFiles.sizeBytes,
libraryId: books.libraryId,
})
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(and(eq(bookFiles.bookId, bookId), eq(bookFiles.role, 'primary')))
.limit(1);
return row ?? null;
}
async findNonMissingBookFilesByLibrary(libraryId: number) {
return this.db
.select({
bookId: books.id,
bookFileId: bookFiles.id,
absolutePath: bookFiles.absolutePath,
format: bookFiles.format,
sizeBytes: bookFiles.sizeBytes,
})
.from(books)
.innerJoin(bookFiles, and(eq(bookFiles.bookId, books.id), eq(bookFiles.role, 'primary')))
.where(and(eq(books.libraryId, libraryId), ne(books.status, 'missing')));
}
async loadPayload(bookId: number) {
const [meta] = await this.db.select().from(bookMetadata).where(eq(bookMetadata.bookId, bookId)).limit(1);
if (!meta) return null;
const [authorRows, genreRows, tagRows] = await Promise.all([
this.db
.select({ name: authors.name, sortName: authors.sortName })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(eq(bookAuthors.bookId, bookId))
.orderBy(bookAuthors.displayOrder),
this.db
.select({ name: genres.name })
.from(bookGenres)
.innerJoin(genres, eq(genres.id, bookGenres.genreId))
.where(eq(bookGenres.bookId, bookId)),
this.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, bookId)),
]);
return {
title: meta.title,
subtitle: meta.subtitle,
description: meta.description,
publisher: meta.publisher,
publishedYear: meta.publishedYear,
language: meta.language,
pageCount: meta.pageCount,
seriesName: meta.seriesName,
seriesIndex: meta.seriesIndex,
isbn10: meta.isbn10,
isbn13: meta.isbn13,
rating: meta.rating,
googleBooksId: meta.googleBooksId,
goodreadsId: meta.goodreadsId,
amazonId: meta.amazonId,
hardcoverId: meta.hardcoverId,
openLibraryId: meta.openLibraryId,
authors: authorRows,
genres: genreRows.map((g) => g.name),
tags: tagRows.map((t) => t.name),
};
}
async insertLog(entry: {
bookId: number;
bookFileId: number | null;
userId: number | null;
format: string;
result: WriteResult;
triggeredBy: 'auto' | 'sync';
}): Promise<void> {
await this.db.insert(fileWriteLog).values({
bookId: entry.bookId,
bookFileId: entry.bookFileId,
userId: entry.userId,
format: entry.format,
status: entry.result.status,
fieldsWritten: entry.result.fieldsWritten,
errorMessage: entry.result.reason ?? null,
durationMs: entry.result.durationMs,
triggeredBy: entry.triggeredBy,
});
}
async setLastWrittenAt(bookId: number, writtenAt: Date): Promise<void> {
await this.db.update(bookMetadata).set({ lastWrittenAt: writtenAt }).where(eq(bookMetadata.bookId, bookId));
}
async findWriteLog(bookId: number, limit = 20): Promise<WriteLogEntry[]> {
const rows = await this.db.select().from(fileWriteLog).where(eq(fileWriteLog.bookId, bookId)).orderBy(desc(fileWriteLog.writtenAt)).limit(limit);
return rows.map((r) => ({
id: r.id,
format: r.format,
status: r.status,
fieldsWritten: (r.fieldsWritten as string[]) ?? [],
triggeredBy: r.triggeredBy,
writtenAt: r.writtenAt.toISOString(),
durationMs: r.durationMs,
errorMessage: r.errorMessage,
}));
}
}
@@ -0,0 +1,146 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
import type { WriteResult } 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<BookWritePayloadKey>([
'title',
'subtitle',
'description',
'publisher',
'publishedYear',
'language',
'pageCount',
'seriesName',
'seriesIndex',
'isbn10',
'isbn13',
'rating',
'authors',
'genres',
'tags',
'googleBooksId',
'goodreadsId',
'amazonId',
'hardcoverId',
'openLibraryId',
'coverBytes',
]);
@Injectable()
export class FileWriteService implements OnModuleDestroy {
private readonly logger = new Logger(FileWriteService.name);
private readonly booksPath: string;
private readonly debounceMap = new Map<number, NodeJS.Timeout>();
constructor(
private readonly fileWriteRepo: FileWriteRepository,
private readonly settingsService: FileWriteSettingsService,
private readonly registry: FormatWriterRegistry,
private readonly lockService: FileLockService,
private readonly config: ConfigService,
) {
this.booksPath = this.config.get<string>('storage.booksPath')!;
}
scheduleWrite(bookId: number, triggeredBy: 'auto' | 'sync', userId?: number): void {
const existing = this.debounceMap.get(bookId);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
this.debounceMap.delete(bookId);
this.logger.debug(`Scheduled write firing for book ${bookId} (${triggeredBy})`);
this.writeToFile(bookId, triggeredBy, userId).catch((err: Error) =>
this.logger.warn(`Background write failed for book ${bookId}: ${err.message}`),
);
}, 3_000);
this.debounceMap.set(bookId, timer);
}
onModuleDestroy(): void {
for (const timer of this.debounceMap.values()) clearTimeout(timer);
this.debounceMap.clear();
}
async writeToFile(bookId: number, triggeredBy: 'auto' | 'sync', userId?: number, dryRun = false): Promise<WriteResult> {
const file = await this.fileWriteRepo.findPrimaryFileForBook(bookId);
if (!file) {
return { status: 'skipped', fieldsWritten: [], durationMs: 0, reason: 'no primary file' };
}
const format = (file.format ?? '').toLowerCase();
if (!this.registry.supports(format)) {
const result: WriteResult = { status: 'skipped', fieldsWritten: [], durationMs: 0, reason: 'format not supported' };
this.logger.debug(`Write skipped for book ${bookId}: format not supported (${format || 'unknown'})`);
if (triggeredBy === 'sync') {
await this.fileWriteRepo.insertLog({ bookId, bookFileId: file.id, userId: userId ?? null, format: format || 'unknown', result, triggeredBy });
}
return result;
}
const settings = await this.settingsService.resolve(file.libraryId);
if (!settings.enabled && !dryRun) {
this.logger.debug(`Write skipped for book ${bookId}: file write is disabled`);
return { status: 'skipped', fieldsWritten: [], durationMs: 0, reason: 'disabled' };
}
const sizeBytes = file.sizeBytes ?? 0;
if (sizeBytes > settings.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}`);
if (triggeredBy === 'sync') {
await this.fileWriteRepo.insertLog({ bookId, bookFileId: file.id, userId: userId ?? null, format, result, triggeredBy });
}
return result;
}
const rawPayload = await this.fileWriteRepo.loadPayload(bookId);
if (!rawPayload) {
return { status: 'skipped', fieldsWritten: [], durationMs: 0, reason: 'no metadata' };
}
const payload: BookWritePayload = { ...rawPayload };
if (settings.writeCover && !dryRun) {
payload.coverBytes = await this.loadCoverBytes(bookId);
}
const writer = this.registry.get(format)!;
let result: WriteResult;
try {
result = await this.lockService.withLock(file.absolutePath, () => writer.write(file.absolutePath, payload, { fieldMask: ALL_FIELDS, dryRun }));
} catch (err) {
result = { status: 'failed', fieldsWritten: [], durationMs: 0, reason: (err as Error).message };
this.logger.warn(`Write failed for book ${bookId}: ${(err as Error).message}`);
await this.fileWriteRepo.insertLog({ bookId, bookFileId: file.id, userId: userId ?? null, format, result, triggeredBy });
return result;
}
await this.fileWriteRepo.insertLog({ bookId, bookFileId: file.id, userId: userId ?? null, format, result, triggeredBy });
if (result.status === 'success') {
await this.fileWriteRepo.setLastWrittenAt(bookId, new Date());
this.logger.log(`Wrote metadata to file for book ${bookId} (${format}, ${result.fieldsWritten.length} fields, ${result.durationMs}ms)`);
}
return result;
}
private async loadCoverBytes(bookId: number): Promise<Buffer | null> {
const dir = join(this.booksPath, 'covers', String(bookId));
try {
const files = await readdir(dir);
const cover = files.find((f) => f.startsWith('cover_custom.')) ?? files.find((f) => f.startsWith('cover_extracted.'));
if (!cover) return null;
return readFile(join(dir, cover));
} catch {
return null;
}
}
}
@@ -0,0 +1,21 @@
import { Inject, Injectable } from '@nestjs/common';
import type { FormatWriter } from './interfaces/format-writer.interface';
import { FORMAT_WRITERS } from './interfaces/format-writer.interface';
@Injectable()
export class FormatWriterRegistry {
private readonly map: Map<string, FormatWriter>;
constructor(@Inject(FORMAT_WRITERS) writers: FormatWriter[]) {
this.map = new Map((writers ?? []).map((w) => [w.format.toLowerCase(), w]));
}
get(format: string): FormatWriter | undefined {
return this.map.get(format.toLowerCase());
}
supports(format: string): boolean {
return this.map.has(format.toLowerCase());
}
}
@@ -0,0 +1,158 @@
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
removeNSPrefix: false,
preserveOrder: true,
isArray: (name) => ['item', 'meta', 'reference'].includes(name),
textNodeName: '#text',
allowBooleanAttributes: true,
});
const builder = new XMLBuilder({
ignoreAttributes: false,
attributeNamePrefix: '@_',
preserveOrder: true,
textNodeName: '#text',
});
type OrderedNode = Record<string, unknown>;
function attr(obj: Record<string, unknown>, key: string): string {
const v = obj[key];
return typeof v === 'string' ? v : '';
}
function nodeTagName(node: OrderedNode): string {
return Object.keys(node).find((k) => k !== ':@') ?? '';
}
export interface CoverSlot {
entryPath: string;
mediaType: string;
}
export interface CoverInjectResult {
updatedOpfXml: string;
newEntryPath: string;
}
function detectMimeFromBytes(bytes: Buffer): { mime: string; ext: string } {
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return { mime: 'image/jpeg', ext: 'jpg' };
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return { mime: 'image/png', ext: 'png' };
return { mime: 'image/jpeg', ext: 'jpg' };
}
function resolvePath(opfDir: string, href: string): string {
const parts = (opfDir + href).split('/');
const resolved: string[] = [];
for (const part of parts) {
if (part === '..') resolved.pop();
else if (part !== '.') resolved.push(part);
}
return resolved.join('/');
}
export function locate(opfXml: string, opfDir: string): CoverSlot | null {
const parsed = parser.parse(opfXml) as OrderedNode[];
let pkgNode: OrderedNode | null = null;
for (const node of parsed) {
if ('package' in node) {
pkgNode = node;
break;
}
}
if (!pkgNode) return null;
const pkgContent = pkgNode['package'] as OrderedNode[];
let manifestItems: Record<string, unknown>[] = [];
let metaMetas: Record<string, unknown>[] = [];
for (const node of pkgContent) {
const tag = nodeTagName(node);
if (tag === 'manifest') {
manifestItems = (node['manifest'] as OrderedNode[])
.filter((n) => nodeTagName(n) === 'item')
.map((n) => (n[':@'] as Record<string, unknown>) ?? {});
}
if (tag === 'metadata' || tag === 'opf:metadata') {
metaMetas = (node[tag] as OrderedNode[]).filter((n) => nodeTagName(n) === 'meta').map((n) => (n[':@'] as Record<string, unknown>) ?? {});
}
}
// Strategy 1: meta[name="cover"] → manifest item by id (EPUB2 — what Calibre/readers follow)
const coverMeta = metaMetas.find((m) => attr(m, '@_name').toLowerCase() === 'cover');
if (coverMeta) {
const coverId = attr(coverMeta, '@_content');
const item = manifestItems.find((i) => attr(i, '@_id') === coverId);
if (item) {
const mt = attr(item, '@_media-type').toLowerCase();
if (mt.startsWith('image/')) {
return { entryPath: resolvePath(opfDir, decodeURIComponent(attr(item, '@_href'))), mediaType: mt };
}
}
}
// Strategy 2: properties="cover-image" (EPUB3)
const epub3Cover = manifestItems.find((i) => {
const props = attr(i, '@_properties');
return props.split(/\s+/).includes('cover-image');
});
if (epub3Cover) {
return {
entryPath: resolvePath(opfDir, decodeURIComponent(attr(epub3Cover, '@_href'))),
mediaType: attr(epub3Cover, '@_media-type'),
};
}
// Strategy 3: exact id match fallback (cover-image, cover, coverimg)
const byId = manifestItems.find((i) => {
const id = attr(i, '@_id').toLowerCase();
const mt = attr(i, '@_media-type').toLowerCase();
return mt.startsWith('image/') && (id === 'cover-image' || id === 'cover' || id === 'coverimg');
});
if (byId) {
return {
entryPath: resolvePath(opfDir, decodeURIComponent(attr(byId, '@_href'))),
mediaType: attr(byId, '@_media-type'),
};
}
return null;
}
export function inject(opfXml: string, opfDir: string, coverBytes: Buffer): CoverInjectResult {
const { mime, ext } = detectMimeFromBytes(coverBytes);
const newEntryPath = `${opfDir}images/cover.${ext}`;
const relHref = newEntryPath.startsWith(opfDir) ? newEntryPath.slice(opfDir.length) : newEntryPath;
const parsed = parser.parse(opfXml) as OrderedNode[];
for (const topNode of parsed) {
if (!('package' in topNode)) continue;
const pkgContent = topNode['package'] as OrderedNode[];
// Add cover-image manifest item
for (const node of pkgContent) {
const tag = nodeTagName(node);
if (tag === 'manifest') {
const manifestContent = node['manifest'] as OrderedNode[];
manifestContent.push({
item: [],
':@': { '@_id': 'cover-image', '@_href': relHref, '@_media-type': mime, '@_properties': 'cover-image' },
} as OrderedNode);
}
// Add <meta name="cover" content="cover-image"/> to metadata for EPUB2 compat
if (tag === 'metadata') {
const metaContent = node['metadata'] as OrderedNode[];
metaContent.push({ meta: [], ':@': { '@_name': 'cover', '@_content': 'cover-image' } } as OrderedNode);
}
}
break;
}
const updatedOpfXml = String(builder.build(parsed));
return { updatedOpfXml, newEntryPath };
}
@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import type { WriteResult } from '@projectx/types';
import type { BookWritePayload, BookWritePayloadKey } from '../../interfaces/book-write-payload.interface';
import type { FormatWriter } from '../../interfaces/format-writer.interface';
import type { FormatWriteOptions } from '../../interfaces/format-write-options.interface';
import { locateOpf } from './epub-opf-locator';
import * as EpubZipPatcher from './epub-zip-patcher';
import { build as buildOpf } from './epub-opf-builder';
import * as EpubCoverHandler from './epub-cover-handler';
@Injectable()
export class EpubFormatWriter implements FormatWriter {
readonly format = 'epub';
async write(filePath: string, payload: BookWritePayload, options: FormatWriteOptions): Promise<WriteResult> {
const start = Date.now();
const { opfPath, opfDir } = await locateOpf(filePath);
const opfXml = await EpubZipPatcher.readEntry(filePath, opfPath);
const { newOpfXml, fieldsWritten } = buildOpf(opfXml, payload, options);
const patches = new Map<string, Buffer>([[opfPath, Buffer.from(newOpfXml)]]);
if (payload.coverBytes && options.fieldMask.has('coverBytes' as BookWritePayloadKey)) {
const slot = EpubCoverHandler.locate(opfXml, opfDir);
if (slot) {
patches.set(slot.entryPath, payload.coverBytes);
fieldsWritten.push('coverBytes');
} else {
const { updatedOpfXml, newEntryPath } = EpubCoverHandler.inject(newOpfXml, opfDir, payload.coverBytes);
patches.set(opfPath, Buffer.from(updatedOpfXml));
patches.set(newEntryPath, payload.coverBytes);
fieldsWritten.push('coverBytes');
}
}
if (options.dryRun) {
return { status: 'skipped', reason: 'dry-run', fieldsWritten, durationMs: Date.now() - start };
}
await EpubZipPatcher.patch(filePath, patches);
return { status: 'success', fieldsWritten, durationMs: Date.now() - start };
}
}
@@ -0,0 +1,383 @@
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
import type { BookWritePayload } from '../../interfaces/book-write-payload.interface';
import type { FormatWriteOptions } from '../../interfaces/format-write-options.interface';
const APP_WRITE_NAMESPACE = 'projectx';
const APP_NS_URI = 'https://projectx.app/metadata/1.0/';
const writerParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
removeNSPrefix: false,
preserveOrder: true,
isArray: (name) => ['dc:creator', 'dc:identifier', 'dc:subject', 'dc:title', 'meta', 'item', 'link', 'reference'].includes(name),
textNodeName: '#text',
allowBooleanAttributes: true,
});
const writerBuilder = new XMLBuilder({
ignoreAttributes: false,
attributeNamePrefix: '@_',
preserveOrder: true,
textNodeName: '#text',
suppressBooleanAttributes: false,
});
type OrderedNode = Record<string, unknown>;
function attr(obj: Record<string, unknown>, key: string): string {
const v = obj[key];
return typeof v === 'string' ? v : '';
}
function creatorId(name: string): string {
return (
'creator-' +
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.slice(0, 30)
);
}
function detectEpubVersion(pkg: Record<string, unknown>): 3 | 2 {
const version = attr(pkg, '@_version');
if (version.startsWith('3')) return 3;
if (version.startsWith('2')) return 2;
throw new Error(`Unsupported EPUB version: "${version || '(missing)'}"`);
}
function getUniqueIdentifierRef(pkg: Record<string, unknown>): string {
return attr(pkg, '@_unique-identifier') || 'uid';
}
function getPackageNode(parsed: OrderedNode[]): { pkgNode: OrderedNode; pkgAttrs: Record<string, unknown>; pkgIndex: number } | null {
for (let i = 0; i < parsed.length; i++) {
const node = parsed[i] as Record<string, unknown>;
if ('package' in node) {
const attrs = (node[':@'] ?? {}) as Record<string, unknown>;
return { pkgNode: node, pkgAttrs: attrs, pkgIndex: i };
}
}
return null;
}
function findMetadataChildren(pkgContent: OrderedNode[]): OrderedNode[] {
for (const node of pkgContent) {
if ('metadata' in node || 'opf:metadata' in node) {
const key = 'metadata' in node ? 'metadata' : 'opf:metadata';
return (node[key] as OrderedNode[]) ?? [];
}
}
return [];
}
function setMetadataChildren(pkgContent: OrderedNode[], children: OrderedNode[]): void {
for (const node of pkgContent) {
if ('metadata' in node) {
(node as Record<string, unknown>)['metadata'] = children;
return;
}
if ('opf:metadata' in node) {
(node as Record<string, unknown>)['opf:metadata'] = children;
return;
}
}
}
function nodeTagName(node: OrderedNode): string {
return Object.keys(node).find((k) => k !== ':@') ?? '';
}
function getNodeAttrs(node: OrderedNode): Record<string, unknown> {
return (node[':@'] as Record<string, unknown>) ?? {};
}
function makeTextNode(tag: string, text: string, attrs: Record<string, unknown> = {}): OrderedNode {
const node: OrderedNode = { [tag]: [{ '#text': text }] };
if (Object.keys(attrs).length > 0) node[':@'] = attrs;
return node;
}
function makeEmptyNode(tag: string, attrs: Record<string, unknown> = {}): OrderedNode {
const node: OrderedNode = { [tag]: [] };
if (Object.keys(attrs).length > 0) node[':@'] = attrs;
return node;
}
// Strips known metadata elements, returns cleaned array + UID node + version
function stripMetadata(children: OrderedNode[], uidRef: string): { cleaned: OrderedNode[]; uidNode: OrderedNode | null } {
const KNOWN_DC = new Set(['dc:title', 'dc:creator', 'dc:description', 'dc:publisher', 'dc:date', 'dc:language', 'dc:subject', 'dc:identifier']);
const CALIBRE_SERIES_NAMES = new Set(['calibre:series', 'calibre:series_index']);
const BELONGS_TO_COLLECTION_PROPS = new Set(['belongs-to-collection', 'collection-type', 'group-position', 'dcterms:modified']);
// Collect ids of dc:creator nodes so we can remove their refines
const creatorIds = new Set<string>();
// Collect id of belonging-to-collection so we can remove their refines
const collectionIds = new Set<string>();
for (const node of children) {
const tag = nodeTagName(node);
if (tag === 'dc:creator') {
const id = attr(getNodeAttrs(node), '@_id');
if (id) creatorIds.add(id);
}
if (tag === 'meta') {
const nodeAttrs = getNodeAttrs(node);
const prop = attr(nodeAttrs, '@_property');
if (prop === 'belongs-to-collection') {
const id = attr(nodeAttrs, '@_id');
if (id) collectionIds.add(id);
}
}
}
let uidNode: OrderedNode | null = null;
const cleaned: OrderedNode[] = [];
for (const node of children) {
const tag = nodeTagName(node);
const nodeAttrs = getNodeAttrs(node);
if (KNOWN_DC.has(tag)) {
// Preserve the unique-identifier dc:identifier
if (tag === 'dc:identifier') {
const nodeId = attr(nodeAttrs, '@_id');
if (nodeId === uidRef) {
uidNode = node;
}
}
// All other known DC elements are stripped
continue;
}
if (tag === 'meta') {
const name = attr(nodeAttrs, '@_name');
const prop = attr(nodeAttrs, '@_property');
const refines = attr(nodeAttrs, '@_refines');
// Strip calibre:series metas
if (CALIBRE_SERIES_NAMES.has(name)) continue;
// Strip belongs-to-collection and its refines
if (BELONGS_TO_COLLECTION_PROPS.has(prop)) continue;
// Strip refines that point to creator ids or collection ids
if (refines) {
const refId = refines.startsWith('#') ? refines.slice(1) : refines;
if (creatorIds.has(refId) || collectionIds.has(refId)) continue;
}
// Strip projectx: named metas
if (name.startsWith(`${APP_WRITE_NAMESPACE}:`)) continue;
// Strip projectx: property metas
if (prop.startsWith(`${APP_WRITE_NAMESPACE}:`)) continue;
}
cleaned.push(node);
}
return { cleaned, uidNode };
}
function buildFreshMetadata(payload: BookWritePayload, epubVersion: 3 | 2, uidNode: OrderedNode | null): OrderedNode[] {
const nodes: OrderedNode[] = [];
// Always re-insert UID node first
if (uidNode) nodes.push(uidNode);
// dc:title
if (payload.title != null) {
if (epubVersion === 3) {
nodes.push(makeTextNode('dc:title', payload.title, { '@_id': 't-main' }));
nodes.push({ meta: [{ '#text': 'main' }], ':@': { '@_refines': '#t-main', '@_property': 'title-type' } } as OrderedNode);
} else {
nodes.push(makeTextNode('dc:title', payload.title));
}
}
// subtitle (EPUB3 as second dc:title, EPUB2 as projectx:subtitle meta)
if (payload.subtitle != null) {
if (epubVersion === 3) {
nodes.push(makeTextNode('dc:title', payload.subtitle, { '@_id': 't-sub' }));
nodes.push({ meta: [{ '#text': 'subtitle' }], ':@': { '@_refines': '#t-sub', '@_property': 'title-type' } } as OrderedNode);
} else {
nodes.push(makeEmptyNode('meta', { '@_name': `${APP_WRITE_NAMESPACE}:subtitle`, '@_content': payload.subtitle }));
}
}
// dc:creator for each author
if (payload.authors?.length) {
for (const author of payload.authors) {
const id = creatorId(author.name);
const fileAs = author.sortName ?? author.name;
if (epubVersion === 3) {
nodes.push(makeTextNode('dc:creator', author.name, { '@_id': id }));
nodes.push({ meta: [{ '#text': 'aut' }], ':@': { '@_refines': `#${id}`, '@_property': 'role', '@_scheme': 'marc:relators' } } as OrderedNode);
nodes.push({ meta: [{ '#text': fileAs }], ':@': { '@_refines': `#${id}`, '@_property': 'file-as' } } as OrderedNode);
} else {
nodes.push(
makeTextNode('dc:creator', author.name, {
'@_opf:role': 'aut',
'@_opf:file-as': fileAs,
}),
);
}
}
}
// dc:description
if (payload.description != null) {
nodes.push(makeTextNode('dc:description', payload.description));
}
// dc:publisher
if (payload.publisher != null) {
nodes.push(makeTextNode('dc:publisher', payload.publisher));
}
// dc:date
if (payload.publishedYear != null) {
nodes.push(makeTextNode('dc:date', `${payload.publishedYear}-01-01`));
}
// dc:language
if (payload.language != null) {
nodes.push(makeTextNode('dc:language', payload.language));
}
// dc:subject per genre
if (payload.genres?.length) {
for (const g of payload.genres) {
nodes.push(makeTextNode('dc:subject', g));
}
}
// ISBN identifiers
if (payload.isbn13 != null) {
nodes.push(makeTextNode('dc:identifier', `urn:isbn:${payload.isbn13}`));
}
if (payload.isbn10 != null) {
if (epubVersion === 3) {
nodes.push({ meta: [{ '#text': payload.isbn10 }], ':@': { '@_property': `${APP_WRITE_NAMESPACE}:isbn10` } } as OrderedNode);
} else {
nodes.push(makeEmptyNode('meta', { '@_name': `${APP_WRITE_NAMESPACE}:isbn10`, '@_content': payload.isbn10 }));
}
}
// Provider identifiers — plain dc:identifier with urn: text, no scheme/id attributes (matches Booklore)
const providerMappings: Array<{ field: keyof BookWritePayload; urnPrefix: string }> = [
{ field: 'goodreadsId', urnPrefix: 'urn:goodreads:' },
{ field: 'amazonId', urnPrefix: 'urn:amazon:' },
{ field: 'hardcoverId', urnPrefix: 'urn:hardcover:' },
{ field: 'googleBooksId', urnPrefix: 'urn:google:' },
{ field: 'openLibraryId', urnPrefix: 'urn:openlibrary:' },
];
for (const { field, urnPrefix } of providerMappings) {
const value = payload[field] as string | null | undefined;
if (value != null) {
nodes.push(makeTextNode('dc:identifier', `${urnPrefix}${value}`));
}
}
// Series - dual write (Calibre EPUB2 + EPUB3 belongs-to-collection)
if (payload.seriesName != null) {
nodes.push(makeEmptyNode('meta', { '@_name': 'calibre:series', '@_content': payload.seriesName }));
if (payload.seriesIndex != null) {
nodes.push(makeEmptyNode('meta', { '@_name': 'calibre:series_index', '@_content': String(payload.seriesIndex) }));
}
const collectionId = 'series-col';
nodes.push(makeTextNode('meta', payload.seriesName, { '@_id': collectionId, '@_property': 'belongs-to-collection' }));
nodes.push({ meta: [{ '#text': 'series' }], ':@': { '@_refines': `#${collectionId}`, '@_property': 'collection-type' } } as OrderedNode);
if (payload.seriesIndex != null) {
nodes.push({
meta: [{ '#text': String(payload.seriesIndex) }],
':@': { '@_refines': `#${collectionId}`, '@_property': 'group-position' },
} as OrderedNode);
}
}
// pageCount
if (payload.pageCount != null) {
if (epubVersion === 3) {
nodes.push({ meta: [{ '#text': String(payload.pageCount) }], ':@': { '@_property': `${APP_WRITE_NAMESPACE}:page_count` } } as OrderedNode);
} else {
nodes.push(makeEmptyNode('meta', { '@_name': `${APP_WRITE_NAMESPACE}:page_count`, '@_content': String(payload.pageCount) }));
}
}
// rating
if (payload.rating != null) {
if (epubVersion === 3) {
nodes.push({ meta: [{ '#text': String(payload.rating) }], ':@': { '@_property': `${APP_WRITE_NAMESPACE}:rating` } } as OrderedNode);
} else {
nodes.push(makeEmptyNode('meta', { '@_name': `${APP_WRITE_NAMESPACE}:rating`, '@_content': String(payload.rating) }));
}
}
// tags as JSON
if (payload.tags?.length) {
const tagsJson = JSON.stringify(payload.tags);
if (epubVersion === 3) {
nodes.push({ meta: [{ '#text': tagsJson }], ':@': { '@_property': `${APP_WRITE_NAMESPACE}:tags` } } as OrderedNode);
} else {
nodes.push(makeEmptyNode('meta', { '@_name': `${APP_WRITE_NAMESPACE}:tags`, '@_content': tagsJson }));
}
}
// EPUB3: dcterms:modified is required
if (epubVersion === 3) {
nodes.push({ meta: [{ '#text': new Date().toISOString() }], ':@': { '@_property': 'dcterms:modified' } } as OrderedNode);
}
return nodes;
}
function getPkgContentAll(pkgNode: OrderedNode): OrderedNode[] {
return pkgNode['package'] as OrderedNode[];
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function build(opfXml: string, payload: BookWritePayload, _options: FormatWriteOptions): { newOpfXml: string; fieldsWritten: string[] } {
const parsed = writerParser.parse(opfXml) as OrderedNode[];
const pkgResult = getPackageNode(parsed);
if (!pkgResult) throw new Error('Cannot find <package> element in OPF');
const { pkgNode, pkgAttrs } = pkgResult;
const epubVersion = detectEpubVersion(pkgAttrs);
const uidRef = getUniqueIdentifierRef(pkgAttrs);
// Ensure projectx namespace prefix is declared on the <package> element for EPUB3.
// pkgAttrs is a live reference to the :@ object inside the parsed tree, so
// mutating it directly is sufficient - no need to walk children.
if (epubVersion === 3) {
const existingPrefix = attr(pkgAttrs, '@_prefix');
const nsDecl = `${APP_WRITE_NAMESPACE}: ${APP_NS_URI}`;
if (!existingPrefix.includes(`${APP_WRITE_NAMESPACE}:`)) {
pkgAttrs['@_prefix'] = existingPrefix ? `${existingPrefix} ${nsDecl}` : nsDecl;
}
}
const pkgContent = getPkgContentAll(pkgNode);
const metaChildren = findMetadataChildren(pkgContent);
const { cleaned, uidNode } = stripMetadata(metaChildren, uidRef);
const freshNodes = buildFreshMetadata(payload, epubVersion, uidNode);
const newMetaChildren = [...freshNodes, ...cleaned];
setMetadataChildren(pkgContent, newMetaChildren);
const newOpfXml = String(writerBuilder.build(parsed));
const fieldsWritten = (Object.keys(payload) as (keyof BookWritePayload)[])
.filter((k) => {
if (k === 'coverBytes') return false;
const v = payload[k];
if (v == null) return false;
if (Array.isArray(v)) return v.length > 0;
return true;
})
.map(String);
return { newOpfXml, fieldsWritten };
}
@@ -0,0 +1,33 @@
import * as unzipper from 'unzipper';
import { XMLParser } from 'fast-xml-parser';
const containerParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
removeNSPrefix: true,
});
export interface OpfLocation {
opfPath: string;
opfDir: string;
}
export async function locateOpf(filePath: string): Promise<OpfLocation> {
const zip = await unzipper.Open.file(filePath);
const containerFile = zip.files.find((f) => f.path === 'META-INF/container.xml');
if (!containerFile) throw new Error('Missing META-INF/container.xml');
const xml = (await containerFile.buffer()).toString('utf-8');
const parsed = containerParser.parse(xml) as Record<string, unknown>;
const container = parsed['container'] as Record<string, unknown> | undefined;
const rootfiles = (container?.['rootfiles'] as Record<string, unknown>)?.['rootfile'];
const rootfile: unknown = Array.isArray(rootfiles) ? rootfiles[0] : rootfiles;
const opfPath = (rootfile as Record<string, unknown> | undefined)?.['@_full-path'];
if (typeof opfPath !== 'string' || !opfPath) {
throw new Error('Cannot locate OPF path in container.xml');
}
const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : '';
return { opfPath, opfDir };
}
@@ -0,0 +1,48 @@
import * as fs from 'fs/promises';
import { createWriteStream } from 'fs';
import * as unzipper from 'unzipper';
import archiver from 'archiver';
export async function readEntry(filePath: string, entryPath: string): Promise<string> {
const zip = await unzipper.Open.file(filePath);
const entry = zip.files.find((f) => f.path === entryPath);
if (!entry) throw new Error(`Entry not found in EPUB: ${entryPath}`);
return (await entry.buffer()).toString('utf-8');
}
export async function patch(filePath: string, patches: Map<string, Buffer>): Promise<void> {
const tmpPath = filePath + '.tmp';
const zip = await unzipper.Open.file(filePath);
const archive = archiver('zip', { zlib: { level: 6 } });
const output = createWriteStream(tmpPath);
await new Promise<void>((resolve, reject) => {
output.on('close', resolve);
output.on('error', reject);
archive.on('error', reject);
archive.pipe(output);
archive.append(Buffer.from('application/epub+zip'), { name: 'mimetype', store: true });
for (const entry of zip.files) {
if (entry.path === 'mimetype') continue;
const patched = patches.get(entry.path);
archive.append(patched ?? entry.stream(), { name: entry.path });
}
for (const [path, content] of patches) {
if (!zip.files.some((e) => e.path === path)) {
archive.append(content, { name: path });
}
}
void archive.finalize();
});
try {
await fs.rename(tmpPath, filePath);
} catch (err) {
await fs.unlink(tmpPath).catch(() => {});
throw err;
}
}
@@ -0,0 +1,25 @@
export interface BookWritePayload {
title?: string | null;
subtitle?: string | null;
description?: string | null;
publisher?: string | null;
publishedYear?: number | null;
language?: string | null;
pageCount?: number | null;
seriesName?: string | null;
seriesIndex?: number | null;
isbn10?: string | null;
isbn13?: string | null;
rating?: number | null;
authors?: { name: string; sortName: string | null }[];
genres?: string[];
tags?: string[];
googleBooksId?: string | null;
goodreadsId?: string | null;
amazonId?: string | null;
hardcoverId?: string | null;
openLibraryId?: string | null;
coverBytes?: Buffer | null;
}
export type BookWritePayloadKey = keyof BookWritePayload;
@@ -0,0 +1,6 @@
import type { BookWritePayloadKey } from './book-write-payload.interface';
export interface FormatWriteOptions {
fieldMask: Set<BookWritePayloadKey>;
dryRun: boolean;
}
@@ -0,0 +1,10 @@
import type { WriteResult } from '@projectx/types';
import type { BookWritePayload } from './book-write-payload.interface';
import type { FormatWriteOptions } from './format-write-options.interface';
export const FORMAT_WRITERS = Symbol('FORMAT_WRITERS');
export interface FormatWriter {
readonly format: string;
write(filePath: string, payload: BookWritePayload, options: FormatWriteOptions): Promise<WriteResult>;
}
@@ -1,9 +1,27 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Patch,
Post,
Query,
Res,
} from '@nestjs/common';
import type { FastifyReply } from 'fastify';
import type { BookQuery } from '@projectx/types';
import type { BookQuery, LibraryFileSyncProgressEvent, WriteResult } from '@projectx/types';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { FileWriteRepository } from '../file-write/file-write.repository';
import { FileWriteService } from '../file-write/file-write.service';
import { FileWriteSettingsService } from '../file-write/file-write-settings.service';
import { BookQueryPipe } from '../book/pipes/book-query.pipe';
import { BookService } from '../book/book.service';
import { CreateLibraryDto } from './dto/create-library.dto';
@@ -19,6 +37,9 @@ export class LibraryController {
constructor(
private readonly libraryService: LibraryService,
private readonly bookService: BookService,
private readonly fileWriteService: FileWriteService,
private readonly fileWriteRepo: FileWriteRepository,
private readonly fileWriteSettings: FileWriteSettingsService,
) {}
@Get()
@@ -75,6 +96,59 @@ export class LibraryController {
return this.libraryService.verifyUserAccess(user.id, id, isSuperuser).then(() => this.libraryService.getStats(id));
}
@Post(':id/write-metadata-to-files')
@RequirePermission('library_edit_metadata')
async writeMetadataToFiles(
@Param('id', ParseIntPipe) libraryId: number,
@Query('dryRun') dryRunParam: string | undefined,
@CurrentUser() user: RequestUser,
@Res() reply: FastifyReply,
) {
const isSuperuser = user.roles.some((r) => r.isSuperuser);
await this.libraryService.verifyUserAccess(user.id, libraryId, isSuperuser);
const dryRun = dryRunParam === 'true';
if (!dryRun) {
const settings = await this.fileWriteSettings.resolve(libraryId);
if (!settings.enabled) {
throw new BadRequestException('Metadata file write is not enabled. Enable it in Maintenance settings first.');
}
}
const rows = await this.fileWriteRepo.findNonMissingBookFilesByLibrary(libraryId);
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
let succeeded = 0;
let failed = 0;
let skipped = 0;
for (const row of rows) {
let result: WriteResult;
try {
result = await this.fileWriteService.writeToFile(row.bookId, 'sync', user.id, dryRun);
} catch (err) {
result = { status: 'failed', fieldsWritten: [], durationMs: 0, reason: (err as Error).message };
}
if (result.status === 'success') succeeded++;
else if (result.status === 'failed') failed++;
else skipped++;
const event: LibraryFileSyncProgressEvent = { bookId: row.bookId, status: result.status, reason: result.reason };
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
}
const doneEvent: LibraryFileSyncProgressEvent = { done: true, processed: rows.length, succeeded, failed, skipped };
reply.raw.write(`data: ${JSON.stringify(doneEvent)}\n\n`);
reply.raw.end();
}
// ── Library access management ─────────────────────────────────────────────
@Get(':libraryId/access')
+2 -1
View File
@@ -1,13 +1,14 @@
import { Module, forwardRef } from '@nestjs/common';
import { BookModule } from '../book/book.module';
import { FileWriteModule } from '../file-write/file-write.module';
import { ScannerModule } from '../scanner/scanner.module';
import { LibraryController } from './library.controller';
import { LibraryRepository } from './library.repository';
import { LibraryService } from './library.service';
@Module({
imports: [ScannerModule, forwardRef(() => BookModule)],
imports: [ScannerModule, forwardRef(() => BookModule), FileWriteModule],
controllers: [LibraryController],
providers: [LibraryService, LibraryRepository],
exports: [LibraryService, LibraryRepository],