mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat: add load-from-file metadata button on book edit tab
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ChevronDown, Loader2, RefreshCw, Sparkles, Star } from 'lucide-vue-next'
|
||||
import { ChevronDown, HardDriveUpload, Loader2, RefreshCw, Sparkles, Star } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { BookDetail, BookMetadataLockField } from '@projectx/types'
|
||||
import { FORMAT_TO_GROUP } from '@projectx/types'
|
||||
@@ -12,6 +12,7 @@ import MetadataFieldLabel from './MetadataFieldLabel.vue'
|
||||
import type { MetadataPatch } from '../../../composables/useMetadataDiff'
|
||||
import { useMetadataEditor } from '../../../composables/useMetadataEditor'
|
||||
import { type MetadataRefreshPreview, useRefreshMetadata } from '../../../composables/useRefreshMetadata'
|
||||
import { type FileMetadata, useFileMetadata } from '../../../composables/useFileMetadata'
|
||||
import { useMetadataLocks } from '../../../composables/useMetadataLocks'
|
||||
import { useAuthorSearch } from '../../../composables/useAuthorSearch'
|
||||
import { useNarratorSearch } from '../../../composables/useNarratorSearch'
|
||||
@@ -254,6 +255,7 @@ function handleApply({ formPatch, coverUrl }: { formPatch: MetadataPatch; coverU
|
||||
}
|
||||
|
||||
const { refreshing: autoFilling, previewRefresh } = useRefreshMetadata()
|
||||
const { loading: loadingFromFile, loadFromFile } = useFileMetadata()
|
||||
|
||||
function buildPreviewPatch(preview: MetadataRefreshPreview): MetadataPatch {
|
||||
return {
|
||||
@@ -296,6 +298,54 @@ async function autoFill() {
|
||||
showApplyResult(skippedFields, updatedCount)
|
||||
}
|
||||
|
||||
function applyFileMetadataToForm(meta: FileMetadata): number {
|
||||
let count = 0
|
||||
const directFields = [
|
||||
'title',
|
||||
'subtitle',
|
||||
'description',
|
||||
'publisher',
|
||||
'publishedYear',
|
||||
'language',
|
||||
'pageCount',
|
||||
'seriesName',
|
||||
'seriesIndex',
|
||||
'isbn10',
|
||||
'isbn13',
|
||||
'authors',
|
||||
'genres',
|
||||
] as const
|
||||
for (const field of directFields) {
|
||||
if (meta[field] !== undefined) {
|
||||
form[field] = meta[field] as never
|
||||
count++
|
||||
}
|
||||
}
|
||||
if (meta.comicMetadata) {
|
||||
for (const [comicKey, formKey] of Object.entries(COMIC_FIELD_MAP) as [
|
||||
keyof typeof COMIC_FIELD_MAP,
|
||||
(typeof COMIC_FIELD_MAP)[keyof typeof COMIC_FIELD_MAP],
|
||||
][]) {
|
||||
const value = meta.comicMetadata[comicKey]
|
||||
if (value !== undefined) {
|
||||
form[formKey] = value as never
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
async function handleLoadFromFile() {
|
||||
const meta = await loadFromFile(props.book.id)
|
||||
if (!meta) {
|
||||
toast.error('Failed to load metadata from file')
|
||||
return
|
||||
}
|
||||
const count = applyFileMetadataToForm(meta)
|
||||
toast.info(count > 0 ? `Loaded ${count} field${count === 1 ? '' : 's'} from file` : 'No metadata found in file')
|
||||
}
|
||||
|
||||
async function handleLockToggle(field: BookMetadataLockField) {
|
||||
const updated = await toggle(props.book.id, field)
|
||||
if (updated) emit('locksChanged', updated.lockedFields)
|
||||
@@ -340,6 +390,25 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
|
||||
<p v-if="combinedError" class="text-sm text-destructive">{{ combinedError }}</p>
|
||||
<span v-else />
|
||||
<div class="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-input bg-background text-sm hover:bg-muted transition-colors disabled:opacity-40"
|
||||
:disabled="loadingFromFile || !primaryFile"
|
||||
@click="handleLoadFromFile"
|
||||
>
|
||||
<Loader2 v-if="loadingFromFile" class="size-3.5 animate-spin" />
|
||||
<HardDriveUpload v-else class="size-3.5" />
|
||||
Load from file
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{
|
||||
loadingFromFile ? 'Loading...' : !primaryFile ? 'No primary file available' : 'Load metadata from the primary book file'
|
||||
}}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div class="w-px h-4 bg-border mx-0.5" />
|
||||
|
||||
<button
|
||||
class="search-online-btn flex items-center gap-1.5 h-8 px-3.5 rounded-lg text-primary-foreground text-sm font-medium transition-all"
|
||||
@click="searchOpen = true"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { ComicMetadataFields } from '@projectx/types'
|
||||
|
||||
export interface FileMetadata {
|
||||
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
|
||||
authors?: string[]
|
||||
genres?: string[]
|
||||
comicMetadata?: ComicMetadataFields
|
||||
}
|
||||
|
||||
export function useFileMetadata() {
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadFromFile(bookId: number): Promise<FileMetadata | null> {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api(`/api/v1/books/${bookId}/metadata-from-file`)
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as FileMetadata
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, loadFromFile }
|
||||
}
|
||||
@@ -369,6 +369,12 @@ export class BookController {
|
||||
return this.bookService.refreshMetadata(id, preview === 'true', user);
|
||||
}
|
||||
|
||||
@Get(':id/metadata-from-file')
|
||||
@RequirePermission(Permission.LibraryEditMetadata)
|
||||
getMetadataFromFile(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
|
||||
return this.bookService.getMetadataFromFile(id, user);
|
||||
}
|
||||
|
||||
@Get(':id/write-log')
|
||||
@RequirePermission(Permission.LibraryEditMetadata)
|
||||
async getWriteLog(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
|
||||
|
||||
@@ -438,6 +438,16 @@ export class BookRepository {
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
async findPrimaryFile(bookId: number): Promise<{ absolutePath: string; format: string | null } | null> {
|
||||
const [row] = await this.db
|
||||
.select({ absolutePath: bookFiles.absolutePath, format: bookFiles.format })
|
||||
.from(books)
|
||||
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
|
||||
.where(eq(books.id, bookId))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async findPrimaryFilesByBookIds(bookIds: number[]): Promise<{ bookId: number; absolutePath: string; format: string | null }[]> {
|
||||
if (bookIds.length === 0) return [];
|
||||
return this.db
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { BadRequestException, Injectable, InternalServerErrorException, Logger, NotFoundException, Optional } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { access, readdir, rm, stat } from 'fs/promises';
|
||||
|
||||
import { extractEpubMetadata } from '../metadata/lib/epub';
|
||||
import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata';
|
||||
import { parseFb2File } from '../metadata/lib/fb2-parser';
|
||||
import { parseMobiFile } from '../metadata/lib/mobi-parser';
|
||||
import { parsePdfFile } from '../metadata/lib/pdf-parser';
|
||||
import { basename, extname, join } from 'path';
|
||||
|
||||
import { DEFAULT_DOWNLOAD_PATTERN, MetadataProviderKey, Permission, isAudioFormat, resolveUploadPath } from '@projectx/types';
|
||||
@@ -1083,6 +1089,103 @@ export class BookService {
|
||||
};
|
||||
}
|
||||
|
||||
async getMetadataFromFile(id: number, user: RequestUser): Promise<Record<string, unknown>> {
|
||||
await this.verifyBookAccess(id, user);
|
||||
const file = await this.bookRepo.findPrimaryFile(id);
|
||||
if (!file) throw new NotFoundException(`Book ${id} has no primary file`);
|
||||
|
||||
const { absolutePath, format } = file;
|
||||
if (!format) return {};
|
||||
|
||||
switch (format) {
|
||||
case 'epub': {
|
||||
const parsed = await extractEpubMetadata(absolutePath);
|
||||
if (!parsed) return {};
|
||||
return {
|
||||
title: parsed.title,
|
||||
subtitle: parsed.subtitle,
|
||||
description: parsed.description,
|
||||
publisher: parsed.publisher,
|
||||
publishedYear: parsed.publishedYear,
|
||||
language: parsed.language,
|
||||
isbn10: parsed.isbn10,
|
||||
isbn13: parsed.isbn13,
|
||||
seriesName: parsed.seriesName,
|
||||
seriesIndex: parsed.seriesIndex,
|
||||
authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined,
|
||||
genres: parsed.tags.length > 0 ? parsed.tags : undefined,
|
||||
};
|
||||
}
|
||||
case 'pdf': {
|
||||
const parsed = await parsePdfFile(absolutePath);
|
||||
if (!parsed) return {};
|
||||
return {
|
||||
title: parsed.title,
|
||||
publisher: parsed.publisher,
|
||||
pageCount: parsed.pageCount,
|
||||
authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined,
|
||||
genres: parsed.genres.length > 0 ? parsed.genres : undefined,
|
||||
};
|
||||
}
|
||||
case 'mobi':
|
||||
case 'azw3':
|
||||
case 'azw': {
|
||||
const parsed = await parseMobiFile(absolutePath);
|
||||
if (!parsed) return {};
|
||||
const year = parsed.publishedDate ? parseInt(parsed.publishedDate.substring(0, 4), 10) || undefined : undefined;
|
||||
return {
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
publisher: parsed.publisher,
|
||||
publishedYear: year,
|
||||
language: parsed.language,
|
||||
isbn13: parsed.isbn,
|
||||
authors: parsed.authors.length > 0 ? parsed.authors : undefined,
|
||||
genres: parsed.tags.length > 0 ? parsed.tags : undefined,
|
||||
};
|
||||
}
|
||||
case 'cbz':
|
||||
case 'cbr':
|
||||
case 'cb7': {
|
||||
const extractor = format === 'cbz' ? extractCbzMetadata : format === 'cbr' ? extractCbrMetadata : extractCb7Metadata;
|
||||
const parsed = await extractor(absolutePath);
|
||||
if (!parsed) return {};
|
||||
return {
|
||||
title: parsed.title,
|
||||
subtitle: parsed.subtitle,
|
||||
description: parsed.description,
|
||||
publisher: parsed.publisher,
|
||||
publishedYear: parsed.publishedYear,
|
||||
language: parsed.language,
|
||||
pageCount: parsed.pageCount,
|
||||
isbn10: parsed.isbn10,
|
||||
isbn13: parsed.isbn13,
|
||||
seriesName: parsed.seriesName,
|
||||
seriesIndex: parsed.seriesIndex,
|
||||
authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined,
|
||||
genres: parsed.tags.length > 0 ? parsed.tags : undefined,
|
||||
comicMetadata: parsed.comicMetadata ?? undefined,
|
||||
};
|
||||
}
|
||||
case 'fb2': {
|
||||
const parsed = await parseFb2File(absolutePath);
|
||||
if (!parsed) return {};
|
||||
return {
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
publishedYear: parsed.publishedYear,
|
||||
language: parsed.language,
|
||||
seriesName: parsed.seriesName,
|
||||
seriesIndex: parsed.seriesIndex,
|
||||
authors: parsed.authors.length > 0 ? parsed.authors : undefined,
|
||||
genres: parsed.genres.length > 0 ? parsed.genres : undefined,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private resolveChapters(
|
||||
stored: AudiobookChapter[] | null | undefined,
|
||||
fileRows: { absolutePath: string; format: string | null; durationSeconds: number | null }[],
|
||||
|
||||
Reference in New Issue
Block a user