mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: add file-write module for writing metadata back to EPUB files
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -61,6 +61,7 @@ export type BookDetail = {
|
||||
genres: string[];
|
||||
tags: string[];
|
||||
files: BookDetailFile[];
|
||||
lastWrittenAt: string | null;
|
||||
};
|
||||
|
||||
export type BooksPage = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -13,3 +13,4 @@ export * from "./scanner";
|
||||
export * from "./opds";
|
||||
export * from "./kobo";
|
||||
export * from "./staging";
|
||||
export * from "./file-write";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1772527273328,
|
||||
"when": 1772569268748,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user