feat(metadata): add per-field locking to protect fields from overwrites

This commit is contained in:
neon
2026-04-03 21:31:02 -06:00
parent abd06c5ccd
commit bab3813ff8
52 changed files with 3551 additions and 502 deletions
+15 -2
View File
@@ -6,6 +6,8 @@ const props = defineProps<{
modelValue: string[]
placeholder?: string
searchFn: (q: string) => Promise<string[]>
disabled?: boolean
controlClass?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [string[]] }>()
@@ -16,6 +18,7 @@ const showDropdown = ref(false)
let debounceTimer: ReturnType<typeof setTimeout>
async function onInput() {
if (props.disabled) return
clearTimeout(debounceTimer)
if (!query.value.trim()) {
results.value = []
@@ -30,6 +33,7 @@ async function onInput() {
}
function addItem(item: string) {
if (props.disabled) return
const trimmed = item.trim()
if (trimmed && !props.modelValue.includes(trimmed)) {
emit('update:modelValue', [...props.modelValue, trimmed])
@@ -40,6 +44,7 @@ function addItem(item: string) {
}
function onKeydown(e: KeyboardEvent) {
if (props.disabled) return
if (e.key === 'Enter' && query.value.trim()) {
e.preventDefault()
addItem(query.value)
@@ -49,6 +54,7 @@ function onKeydown(e: KeyboardEvent) {
}
function removeItem(item: string) {
if (props.disabled) return
emit(
'update:modelValue',
props.modelValue.filter((v) => v !== item),
@@ -68,17 +74,24 @@ onUnmounted(() => clearTimeout(debounceTimer))
<div class="relative">
<div
class="min-h-10 flex flex-wrap gap-1.5 rounded-md border border-input bg-background px-3 py-2 text-sm focus-within:ring-1 focus-within:ring-ring transition-shadow"
:class="[props.disabled ? 'cursor-not-allowed opacity-60' : '', props.controlClass]"
>
<span v-for="item in modelValue" :key="item" class="flex items-center gap-1 bg-muted px-2 py-0.5 rounded-full text-xs">
{{ item }}
<button type="button" class="text-muted-foreground hover:text-foreground transition-colors" @click="removeItem(item)">
<button
type="button"
class="text-muted-foreground hover:text-foreground transition-colors disabled:pointer-events-none"
:disabled="props.disabled"
@click="removeItem(item)"
>
<X class="size-3" />
</button>
</span>
<input
v-model="query"
class="flex-1 min-w-24 bg-transparent outline-none placeholder:text-muted-foreground"
class="flex-1 min-w-24 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
:placeholder="modelValue.length === 0 ? placeholder : ''"
:disabled="props.disabled"
@input="onInput"
@keydown="onKeydown"
@blur="onBlur"
@@ -88,6 +88,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
audioMetadata: null,
formatPriority: [],
comicMetadata: null,
lockedFields: [],
collections: [],
...overrides,
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, inject, ref, onUnmounted } from 'vue'
import { Image, ImagePlus, Link, Loader2, RotateCcw, Search, Upload, X } from 'lucide-vue-next'
import { Image, ImagePlus, Link, Lock, LockOpen, Loader2, RotateCcw, Search, Upload, X } from 'lucide-vue-next'
import type { BookDetail } from '@projectx/types'
import { FORMAT_TO_GROUP } from '@projectx/types'
import { hideOnError } from '../../../lib/metadata-fetch'
@@ -10,8 +10,8 @@ import { usePermissions } from '@/features/auth/composables/usePermissions'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
import CoverSearchDrawer from './CoverSearchDrawer.vue'
const props = defineProps<{ book: BookDetail }>()
const emit = defineEmits<{ coverChanged: ['extracted' | 'custom' | null] }>()
const props = defineProps<{ book: BookDetail; locked?: boolean }>()
const emit = defineEmits<{ coverChanged: ['extracted' | 'custom' | null]; toggleLock: [] }>()
const bookIdRef = computed(() => props.book.id)
const { uploading, error, previewSrc, pendingFile, pendingUrl, selectFile, setUrl, clearPending, confirm, revert } = useCoverEditor(bookIdRef)
@@ -22,7 +22,7 @@ const { hasPermission } = usePermissions()
const reExtractingCover = ref(false)
async function reExtractCover() {
if (reExtractingCover.value) return
if (props.locked || reExtractingCover.value) return
reExtractingCover.value = true
try {
await fetch(`/api/v1/books/${props.book.id}/re-extract-cover`, { method: 'POST' })
@@ -51,11 +51,13 @@ function cancelPending() {
}
function onFileChange(e: Event) {
if (props.locked) return
const file = (e.target as HTMLInputElement).files?.[0]
if (file) selectFile(file)
}
function onUrlInput() {
if (props.locked) return
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => setUrl(urlInput.value.trim()), 400)
}
@@ -73,11 +75,13 @@ function handleSearchSelect(url: string) {
}
async function handleConfirm() {
if (props.locked) return
const ok = await confirm()
if (ok) emit('coverChanged', 'custom')
}
async function handleRevert() {
if (props.locked) return
const result = await revert()
if (result !== false) emit('coverChanged', result)
}
@@ -98,6 +102,15 @@ onUnmounted(() => clearTimeout(debounceTimer))
@click="lightboxOpen = true"
>
<img :src="activeSrc" :alt="book.title ?? ''" class="w-full h-full object-contain" @error="hideOnError" />
<button
type="button"
class="absolute bottom-2 right-2 flex items-center justify-center size-6 rounded-md bg-background/90 shadow-sm border border-input hover:bg-muted transition-colors"
:title="props.locked ? 'Unlock cover' : 'Lock cover'"
@click.stop="emit('toggleLock')"
>
<Lock v-if="props.locked" class="size-3.5 text-primary/70" />
<LockOpen v-else class="size-3.5 text-muted-foreground" />
</button>
</div>
<!-- Lightbox -->
@@ -116,16 +129,18 @@ onUnmounted(() => clearTimeout(debounceTimer))
<!-- Mode toggle -->
<div class="flex gap-1 p-0.5 rounded-lg bg-muted">
<button
class="flex flex-1 items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors"
class="flex flex-1 items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:class="mode === 'file' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
:disabled="props.locked"
@click="switchMode('file')"
>
<ImagePlus class="size-3.5" />
File
</button>
<button
class="flex flex-1 items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors"
class="flex flex-1 items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:class="mode === 'url' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
:disabled="props.locked"
@click="switchMode('url')"
>
<Link class="size-3.5" />
@@ -136,11 +151,12 @@ onUnmounted(() => clearTimeout(debounceTimer))
<!-- File input -->
<div v-if="mode === 'file'">
<label
class="flex items-center gap-2 h-9 px-3 rounded-lg border border-dashed border-input bg-background text-xs text-muted-foreground hover:text-foreground hover:border-foreground/40 transition-colors cursor-pointer"
class="flex items-center gap-2 h-9 px-3 rounded-lg border border-dashed border-input bg-background text-xs text-muted-foreground hover:text-foreground hover:border-foreground/40 transition-colors"
:class="props.locked ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
>
<Upload class="size-3.5 shrink-0" />
<span class="truncate">{{ pendingFile ? pendingFile.name : 'Choose image...' }}</span>
<input type="file" accept="image/*" class="hidden" @change="onFileChange" />
<input type="file" accept="image/*" class="hidden" :disabled="props.locked" @change="onFileChange" />
</label>
</div>
@@ -149,13 +165,15 @@ onUnmounted(() => clearTimeout(debounceTimer))
<input
v-model="urlInput"
class="w-full h-9 rounded-lg border border-input bg-background px-3 text-xs outline-none focus:ring-1 focus:ring-ring transition-shadow"
:disabled="props.locked"
@input="onUrlInput"
/>
</div>
<!-- Search Button -->
<button
class="flex items-center justify-center gap-2 w-full h-9 rounded-lg border border-input bg-background text-xs font-medium hover:bg-muted transition-colors"
class="flex items-center justify-center gap-2 w-full h-9 rounded-lg border border-input bg-background text-xs font-medium hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="props.locked"
@click="isSearchOpen = true"
>
<Search class="size-3.5" />
@@ -182,7 +200,7 @@ onUnmounted(() => clearTimeout(debounceTimer))
<button
v-if="hasPending"
class="w-full h-8 rounded-lg bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="uploading"
:disabled="uploading || props.locked"
@click="handleConfirm"
>
{{ uploading ? 'Saving...' : 'Save cover' }}
@@ -190,7 +208,7 @@ onUnmounted(() => clearTimeout(debounceTimer))
<button
v-if="hasPending"
class="w-full h-8 rounded-lg border border-input bg-background text-xs hover:bg-muted transition-colors disabled:opacity-50"
:disabled="uploading"
:disabled="uploading || props.locked"
@click="cancelPending"
>
Cancel
@@ -198,7 +216,7 @@ onUnmounted(() => clearTimeout(debounceTimer))
<button
v-if="book.coverSource === 'custom'"
class="flex items-center justify-center gap-1.5 w-full h-8 rounded-lg border border-input bg-background text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
:disabled="uploading"
:disabled="uploading || props.locked"
@click="handleRevert"
>
<RotateCcw class="size-3" />
@@ -207,7 +225,7 @@ onUnmounted(() => clearTimeout(debounceTimer))
<button
v-if="hasPermission('library_edit_metadata')"
class="flex items-center justify-center gap-1.5 w-full h-8 rounded-lg border border-input bg-background text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
:disabled="reExtractingCover"
:disabled="reExtractingCover || props.locked"
@click="reExtractCover"
>
<Loader2 v-if="reExtractingCover" class="size-3 animate-spin" />
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, Check, ChevronDown, FolderPlus, Headphones, MoreHorizontal, Pencil, Star, Trash2, TriangleAlert, X } from 'lucide-vue-next'
import { BookOpen, Check, ChevronDown, FolderPlus, Headphones, Lock, MoreHorizontal, Pencil, Star, Trash2, TriangleAlert, X } from 'lucide-vue-next'
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { getFormatColor } from '@/features/book/lib/format-colors'
@@ -19,6 +19,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { api } from '@/lib/api'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
import { useMetadataLocks } from '@/features/book/composables/useMetadataLocks'
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
import MetadataScoreBadge from '@/features/metadata-score/components/MetadataScoreBadge.vue'
@@ -219,6 +220,14 @@ onBeforeUnmount(() => {
})
const { hasPermission } = usePermissions()
const { load: loadLocks, isLocked } = useMetadataLocks()
watch(
() => props.book,
(b) => loadLocks(b),
{ immediate: true },
)
const isRatingLocked = computed(() => isLocked('rating'))
const canViewKobo = computed(() => hasPermission('kobo_sync'))
const canEditMetadata = computed(() => hasPermission('library_edit_metadata'))
@@ -873,24 +882,44 @@ watch(
</div>
<div class="mt-3 flex items-center gap-1" @mouseleave="hoverRating = null">
<template v-if="canEditMetadata">
<Tooltip v-for="star in ratingStars" :key="star">
<div class="flex items-center gap-1">
<template v-if="canEditMetadata">
<Tooltip v-for="star in ratingStars" :key="star">
<TooltipTrigger as-child>
<button
type="button"
class="p-0.5 transition-colors"
:class="isRatingLocked ? 'pointer-events-none' : 'disabled:opacity-50'"
:disabled="isRatingLocked"
@mouseenter="hoverRating = star"
@click="setRating(star)"
>
<Star class="size-3.5" :class="(displayRating ?? 0) >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/60'" />
</button>
</TooltipTrigger>
<TooltipContent>{{ isRatingLocked ? 'Rating is locked' : `Rate ${star}` }}</TooltipContent>
</Tooltip>
</template>
<template v-else>
<Star
v-for="star in ratingStars"
:key="star"
class="size-3.5"
:class="(localRating ?? 0) >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/60'"
/>
</template>
</div>
<template v-if="isRatingLocked">
<Tooltip>
<TooltipTrigger as-child>
<button type="button" class="p-0.5 transition-colors" @mouseenter="hoverRating = star" @click="setRating(star)">
<Star class="size-3.5" :class="(displayRating ?? 0) >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/60'" />
</button>
<div class="ml-1 p-1 rounded-full bg-primary/10 text-primary">
<Lock class="size-3" />
</div>
</TooltipTrigger>
<TooltipContent>Rate {{ star }}</TooltipContent>
<TooltipContent>Rating is locked</TooltipContent>
</Tooltip>
</template>
<template v-else>
<Star
v-for="star in ratingStars"
:key="star"
class="size-3.5"
:class="(localRating ?? 0) >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/60'"
/>
</template>
<div class="w-px h-3.5 bg-border mx-1.5" />
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,14 @@
<script setup lang="ts">
import { computed, inject, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ArrowLeft, CopyCheck, Copy, CheckCheck, Eye, EyeOff, Info } from 'lucide-vue-next'
import type { MetadataCandidate, MetadataProviderInfo, MetadataProviderKey, MetadataSource, ProviderIds } from '@projectx/types'
import type {
BookMetadataLockField,
MetadataCandidate,
MetadataProviderInfo,
MetadataProviderKey,
MetadataSource,
ProviderIds,
} from '@projectx/types'
import { useMetadataDiff, type DiffFieldKey, type MetadataPatch } from '../../../composables/useMetadataDiff'
import { getProviderColor, getProviderLabel, hideOnError, providerActivePillStyle } from '../../../lib/metadata-fetch'
import MetadataDiffRow from './MetadataDiffRow.vue'
@@ -16,6 +23,7 @@ const props = defineProps<{
backLabel?: string
currentCoverUrl?: string
providerIds?: ProviderIds
lockedFields?: BookMetadataLockField[]
}>()
const emit = defineEmits<{
@@ -85,6 +93,7 @@ const { fields, picksPerProvider, toggleField, pickFieldFromProvider, clearPicks
computed(() => props.providers),
props.currentCoverUrl,
computed(() => props.providerIds),
computed(() => props.lockedFields ?? []),
)
const activeProviderLabel = computed(() => getProviderLabel(activeProvider.value, props.providers))
@@ -84,7 +84,12 @@ watch(
<template>
<!-- Cover row -->
<div v-if="field.isCover" class="py-3.5 border-b border-border/40">
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground mb-2.5">{{ field.label }}</p>
<div class="mb-2.5 flex items-center gap-2">
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">{{ field.label }}</p>
<span v-if="field.isLocked" class="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[9px] font-medium text-primary">
Locked
</span>
</div>
<div class="grid grid-cols-[1fr_auto_1fr] gap-2 items-center">
<!-- Current cover -->
<div
@@ -136,6 +141,7 @@ watch(
? 'bg-primary text-primary-foreground shadow-primary/30'
: 'bg-card border border-border text-muted-foreground hover:border-primary/40 hover:text-primary hover:bg-primary/5'
"
:disabled="field.isLocked"
@click="handleToggle"
>
<RotateCcw v-if="field.isPicked && field.pickedFromActive" class="size-3.5" />
@@ -143,7 +149,7 @@ watch(
<ArrowLeft v-else class="size-3.5" />
</button>
<Popover v-if="field.isCopyable && field.providerValues.length >= 2">
<Popover v-if="field.isCopyable && field.providerValues.length >= 2 && !field.isLocked">
<PopoverTrigger as-child>
<button
class="size-5 rounded-full flex items-center justify-center transition-all hover:bg-muted text-muted-foreground hover:text-foreground"
@@ -212,7 +218,12 @@ watch(
<!-- Text row -->
<div v-else class="py-2.5 border-b border-border/40">
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground mb-1.5">{{ field.label }}</p>
<div class="mb-1.5 flex items-center gap-2">
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">{{ field.label }}</p>
<span v-if="field.isLocked" class="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[9px] font-medium text-primary">
Locked
</span>
</div>
<div class="flex flex-col gap-2 sm:grid sm:grid-cols-[1fr_auto_1fr] sm:gap-1.5 sm:items-stretch">
<!-- Current value -->
@@ -250,6 +261,7 @@ watch(
? 'bg-primary text-primary-foreground shadow-primary/30'
: 'bg-card border border-border text-muted-foreground hover:border-primary/40 hover:text-primary hover:bg-primary/5'
"
:disabled="field.isLocked"
@click="handleToggle"
>
<RotateCcw v-if="field.isPicked && field.pickedFromActive" class="size-3.5" />
@@ -261,7 +273,7 @@ watch(
</div>
<div v-else class="size-8 shrink-0" />
<Popover v-if="field.isCopyable && field.providerValues.length >= 2">
<Popover v-if="field.isCopyable && field.providerValues.length >= 2 && !field.isLocked">
<PopoverTrigger as-child>
<button
class="size-5 rounded-full flex items-center justify-center transition-all hover:bg-muted text-muted-foreground hover:text-foreground shrink-0"
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Loader2, Lock, LockOpen } from 'lucide-vue-next'
import type { BookMetadataLockField } from '@projectx/types'
const props = defineProps<{
label: string
field: BookMetadataLockField
locked: boolean
isUpdating?: (field: BookMetadataLockField) => boolean
multiline?: boolean
}>()
const emit = defineEmits<{ toggle: [field: BookMetadataLockField] }>()
const loading = computed(() => props.isUpdating?.(props.field) ?? false)
function handleToggle() {
emit('toggle', props.field)
}
</script>
<template>
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">{{ label }}</label>
<div class="relative">
<slot />
<button
type="button"
class="absolute right-2 z-10 inline-flex size-6 cursor-pointer items-center justify-center rounded-md border border-input bg-background/95 text-muted-foreground shadow-sm transition-[colors,opacity] hover:text-foreground hover:bg-muted disabled:cursor-not-allowed"
:class="[multiline ? 'top-2.5' : 'top-1/2 -translate-y-1/2', loading ? 'opacity-60' : 'opacity-100']"
:aria-label="locked ? `Unlock ${label}` : `Lock ${label}`"
:title="locked ? `Unlock ${label}` : `Lock ${label}`"
:disabled="loading"
@click="handleToggle"
>
<Transition name="icon" mode="out-in">
<Loader2 v-if="loading" key="loading" class="size-3.5 animate-spin" />
<Lock v-else-if="locked" key="locked" class="size-3.5 text-primary" />
<LockOpen v-else key="unlocked" class="size-3.5" />
</Transition>
</button>
</div>
</div>
</template>
<style scoped>
.icon-enter-active,
.icon-leave-active {
transition:
opacity 120ms ease,
transform 120ms ease;
}
.icon-enter-from {
opacity: 0;
transform: scale(0.7);
}
.icon-leave-to {
opacity: 0;
transform: scale(0.7);
}
</style>
@@ -2,14 +2,14 @@
import { computed, onMounted, ref } from 'vue'
import { X, Sparkles } from 'lucide-vue-next'
import { isAudioFormat } from '@projectx/types'
import type { BookDetail, MetadataCandidate, MetadataSource } from '@projectx/types'
import type { BookDetail, BookMetadataLockField, MetadataCandidate, MetadataSource } from '@projectx/types'
import { useMetadataSearch } from '../../../composables/useMetadataSearch'
import { useCoverVersions } from '../../../composables/useCoverVersions'
import type { MetadataPatch } from '../../../composables/useMetadataDiff'
import MetadataSearchPanel from './MetadataSearchPanel.vue'
import MetadataDiffPanel from './MetadataDiffPanel.vue'
const props = defineProps<{ book: BookDetail }>()
const props = defineProps<{ book: BookDetail; lockedFields: BookMetadataLockField[] }>()
const emit = defineEmits<{
close: []
apply: [{ formPatch: MetadataPatch; coverUrl?: string }]
@@ -156,6 +156,7 @@ function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string }) {
:providers="providers"
:current-cover-url="bookCoverUrl"
:provider-ids="book.providerIds"
:locked-fields="props.lockedFields"
:filtered-results="filteredResults"
back-label="Results"
@back="backToSearch"
@@ -0,0 +1,41 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import MetadataFieldLabel from '../MetadataFieldLabel.vue'
describe('MetadataFieldLabel', () => {
it('renders the lock button inside the control wrapper', () => {
const wrapper = mount(MetadataFieldLabel, {
props: {
label: 'Title',
field: 'title',
locked: false,
},
slots: {
default: '<input data-test="control" class="w-full pr-10" />',
},
})
const button = wrapper.get('button[aria-label="Lock Title"]')
const control = wrapper.get('[data-test="control"]')
expect(button.classes()).toContain('absolute')
expect(button.element.parentElement).toBe(control.element.parentElement)
})
it('emits the target field when toggled', async () => {
const wrapper = mount(MetadataFieldLabel, {
props: {
label: 'Title',
field: 'title',
locked: true,
},
slots: {
default: '<input class="w-full pr-10" />',
},
})
await wrapper.get('button[aria-label="Unlock Title"]').trigger('click')
expect(wrapper.emitted('toggle')).toEqual([['title']])
})
})
@@ -173,4 +173,25 @@ describe('useMetadataDiff', () => {
]),
)
})
it('marks locked fields and prevents them from being picked', () => {
const candidates = ref([mockCandidate1])
const activeProvider = ref<MetadataProviderKey>('google')
const lockedFields = ref(['title'] as const)
const { fields, toggleField, buildPatch } = useMetadataDiff(
mockCurrent,
candidates,
activeProvider,
providers,
undefined,
undefined,
lockedFields,
)
toggleField('title')
expect(fields.value.find((f) => f.key === 'title')?.isLocked).toBe(true)
expect(fields.value.find((f) => f.key === 'title')?.isPicked).toBe(false)
expect(buildPatch().formPatch.title).toBeUndefined()
})
})
@@ -51,6 +51,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
audioMetadata: null,
formatPriority: [],
comicMetadata: null,
lockedFields: [],
collections: [],
...overrides,
}
@@ -97,8 +98,9 @@ describe('useMetadataEditor', () => {
})
apiMock.mockResolvedValue({ ok: true, json: async () => book })
const { load, save } = useMetadataEditor()
const { form, load, save } = useMetadataEditor()
load(book)
form.narrators = ['Narrator Two']
await save(book.id)
const [, req] = apiMock.mock.calls[0] as [string, RequestInit]
@@ -106,9 +108,23 @@ describe('useMetadataEditor', () => {
audioMetadata?: { narrators?: string[]; durationSeconds?: number | null; abridged?: boolean }
}
expect(payload.audioMetadata).toEqual({
narrators: ['Narrator One'],
durationSeconds: 3600,
abridged: false,
narrators: ['Narrator Two'],
})
})
it('sends only changed fields in the metadata payload', async () => {
const book = makeBook({ title: 'Original Title', publisher: 'Original Publisher' })
apiMock.mockResolvedValue({ ok: true, json: async () => book })
const { form, load, save } = useMetadataEditor()
load(book)
form.publisher = 'Updated Publisher'
await save(book.id)
const [, req] = apiMock.mock.calls[0] as [string, RequestInit]
const payload = JSON.parse(String(req.body)) as Record<string, unknown>
expect(payload).toEqual({
publisher: 'Updated Publisher',
})
})
})
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BookDetail } from '@projectx/types'
import { useMetadataLocks } from '../useMetadataLocks'
const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<unknown>>())
vi.mock('@/lib/api', () => ({
api: apiMock,
}))
function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
return {
id: 1,
libraryId: 1,
libraryName: 'Test Library',
status: 'present',
folderPath: '/books',
addedAt: '2026-01-01T00:00:00.000Z',
title: 'Test Book',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
pageCount: null,
seriesName: null,
seriesIndex: null,
rating: null,
coverSource: null,
providerIds: {},
authors: [],
genres: [],
tags: [],
files: [],
lastWrittenAt: null,
metadataScore: null,
readStatus: null,
audioMetadata: null,
formatPriority: [],
comicMetadata: null,
lockedFields: [],
collections: [],
...overrides,
}
}
describe('useMetadataLocks', () => {
beforeEach(() => {
apiMock.mockReset()
})
it('loads current lock state from the book detail', () => {
const { load, isLocked } = useMetadataLocks()
load(makeBook({ lockedFields: ['authors', 'cover'] }))
expect(isLocked('authors')).toBe(true)
expect(isLocked('cover')).toBe(true)
expect(isLocked('title')).toBe(false)
})
it('toggles a field by replacing the full lock set', async () => {
const updated = makeBook({ lockedFields: ['title'] })
apiMock.mockResolvedValue({ ok: true, json: async () => updated })
const { load, toggle } = useMetadataLocks()
load(makeBook())
const result = await toggle(1, 'title')
const [, req] = apiMock.mock.calls[0] as [string, RequestInit]
expect(JSON.parse(String(req.body))).toEqual({ lockedFields: ['title'] })
expect(result?.lockedFields).toEqual(['title'])
})
it('lockAll sends every lockable field', async () => {
const { BOOK_METADATA_LOCK_FIELDS } = await import('@projectx/types')
const allLocked = makeBook({ lockedFields: [...BOOK_METADATA_LOCK_FIELDS] })
apiMock.mockResolvedValue({ ok: true, json: async () => allLocked })
const { lockAll, areAllLocked } = useMetadataLocks()
const result = await lockAll(1)
expect(result?.lockedFields).toHaveLength(BOOK_METADATA_LOCK_FIELDS.length)
expect(areAllLocked.value).toBe(true)
})
it('unlockAll sends an empty lock set', async () => {
const unlocked = makeBook({ lockedFields: [] })
apiMock.mockResolvedValue({ ok: true, json: async () => unlocked })
const { load, unlockAll, lockedFields } = useMetadataLocks()
load(makeBook({ lockedFields: ['title', 'cover'] }))
const result = await unlockAll(1)
const [, req] = apiMock.mock.calls[0] as [string, RequestInit]
expect(JSON.parse(String(req.body))).toEqual({ lockedFields: [] })
expect(result?.lockedFields).toEqual([])
expect(lockedFields.value).toEqual([])
})
it('sets error and returns null on network failure', async () => {
apiMock.mockRejectedValue(new Error('network error'))
const { toggle, error } = useMetadataLocks()
const result = await toggle(1, 'title')
expect(result).toBeNull()
expect(error.value).toBe('network error')
})
it('sets error and returns null on non-ok response', async () => {
apiMock.mockResolvedValue({ ok: false, status: 409 })
const { toggle, error } = useMetadataLocks()
const result = await toggle(1, 'title')
expect(result).toBeNull()
expect(error.value).toBe('HTTP 409')
})
})
@@ -1,5 +1,13 @@
import { computed, reactive, toValue, type MaybeRefOrGetter } from 'vue'
import type { ComicMetadataFields, MetadataCandidate, MetadataProviderInfo, MetadataProviderKey, MetadataSource, ProviderIds } from '@projectx/types'
import type {
BookMetadataLockField,
ComicMetadataFields,
MetadataCandidate,
MetadataProviderInfo,
MetadataProviderKey,
MetadataSource,
ProviderIds,
} from '@projectx/types'
import { getProviderLabel } from '../lib/metadata-fetch'
type ComicDiffFieldKey =
@@ -56,6 +64,7 @@ export interface DiffField {
pickedProvider: MetadataProviderKey | null
pickedDisplay: string
isCover: boolean
isLocked: boolean
isCopyable: boolean
providerValues: ProviderFieldValue[]
}
@@ -198,8 +207,16 @@ export function useMetadataDiff(
providerInfos: MaybeRefOrGetter<MetadataProviderInfo[]>,
currentCoverUrl?: string,
providerIds?: MaybeRefOrGetter<ProviderIds | undefined>,
lockedFields?: MaybeRefOrGetter<readonly BookMetadataLockField[] | undefined>,
) {
const pickedSources = reactive(new Map<DiffFieldKey, MetadataProviderKey>())
const lockedFieldSet = computed(() => new Set(toValue(lockedFields) ?? []))
function resolveLockField(key: DiffFieldKey): BookMetadataLockField | null {
if (key === 'coverUrl') return 'cover'
if (key === 'sourceUrl') return null
return key
}
function getBookValue(key: DiffFieldKey): string {
if (isComicDiffFieldKey(key)) return ''
@@ -251,6 +268,7 @@ export function useMetadataDiff(
const pickedFromActive = isPicked && pickedProvider === ap
const pickedCandidate = isPicked ? allCandidates.find((c) => c.provider === pickedProvider) : null
const pickedDisplay = pickedCandidate ? getCandidateValueFrom(pickedCandidate, key) : ''
const lockField = resolveLockField(key)
return {
key,
@@ -264,6 +282,7 @@ export function useMetadataDiff(
pickedProvider,
pickedDisplay,
isCover: key === 'coverUrl',
isLocked: lockField ? lockedFieldSet.value.has(lockField) : false,
isCopyable: true,
providerValues: buildProviderValues(key),
}
@@ -310,6 +329,7 @@ export function useMetadataDiff(
pickedProvider: pickedFromActive ? pickedProvider : null,
pickedDisplay: pickedFromActive ? pickedDisplay : '',
isCover: false,
isLocked: lockedFieldSet.value.has(providerIdKey),
isCopyable: true,
providerValues: [],
})
@@ -328,6 +348,7 @@ export function useMetadataDiff(
pickedProvider: null,
pickedDisplay: '',
isCover: false,
isLocked: false,
isCopyable: false,
providerValues: [],
})
@@ -337,6 +358,8 @@ export function useMetadataDiff(
})
function toggleField(key: DiffFieldKey) {
const lockField = resolveLockField(key)
if (lockField && lockedFieldSet.value.has(lockField)) return
const ap = toValue(activeProvider)
if (pickedSources.get(key) === ap) {
pickedSources.delete(key)
@@ -346,6 +369,8 @@ export function useMetadataDiff(
}
function pickFieldFromProvider(key: DiffFieldKey, provider: MetadataProviderKey) {
const lockField = resolveLockField(key)
if (lockField && lockedFieldSet.value.has(lockField)) return
if (pickedSources.get(key) === provider) {
pickedSources.delete(key)
} else {
@@ -356,14 +381,14 @@ export function useMetadataDiff(
function copyAll() {
const ap = toValue(activeProvider)
for (const f of fields.value) {
if (f.isCopyable) pickedSources.set(f.key, ap)
if (f.isCopyable && !f.isLocked) pickedSources.set(f.key, ap)
}
}
function copyMissing() {
const ap = toValue(activeProvider)
for (const f of fields.value) {
if (f.isCopyable && !f.bookValue && !f.isPicked) pickedSources.set(f.key, ap)
if (f.isCopyable && !f.isLocked && !f.bookValue && !f.isPicked) pickedSources.set(f.key, ap)
}
}
@@ -374,6 +399,8 @@ export function useMetadataDiff(
const allCandidates = toValue(candidates)
for (const [key, providerKey] of pickedSources) {
const lockField = resolveLockField(key)
if (lockField && lockedFieldSet.value.has(lockField)) continue
const candidate = allCandidates.find((c) => c.provider === providerKey)
if (!candidate) continue
@@ -2,6 +2,52 @@ import { computed, reactive, ref } from 'vue'
import { api } from '@/lib/api'
import { FORMAT_TO_GROUP, type BookDetail } from '@projectx/types'
const ROOT_FIELDS = [
'title',
'subtitle',
'description',
'publisher',
'publishedYear',
'language',
'pageCount',
'seriesName',
'seriesIndex',
'isbn10',
'isbn13',
'rating',
'authors',
'genres',
'tags',
'googleBooksId',
'goodreadsId',
'amazonId',
'hardcoverId',
'openLibraryId',
'itunesId',
'audibleId',
'comicvineId',
] as const
const COMIC_FIELDS = {
issueNumber: 'comicIssueNumber',
volumeName: 'comicVolumeName',
storyArcs: 'comicStoryArcs',
pencillers: 'comicPencillers',
inkers: 'comicInkers',
colorists: 'comicColorists',
letterers: 'comicLetterers',
coverArtists: 'comicCoverArtists',
characters: 'comicCharacters',
teams: 'comicTeams',
locations: 'comicLocations',
} as const
const AUDIO_FIELDS = {
narrators: 'narrators',
durationSeconds: 'durationSeconds',
abridged: 'abridged',
} as const
export function useMetadataEditor() {
const saving = ref(false)
const error = ref<string | null>(null)
@@ -102,47 +148,34 @@ export function useMetadataEditor() {
}
function buildPayload() {
const {
comicIssueNumber,
comicVolumeName,
comicStoryArcs,
comicPencillers,
comicInkers,
comicColorists,
comicLetterers,
comicCoverArtists,
comicCharacters,
comicTeams,
comicLocations,
narrators,
durationSeconds,
abridged,
...rest
} = form
const payload = {
...rest,
comicMetadata: {
issueNumber: comicIssueNumber ?? undefined,
volumeName: comicVolumeName ?? undefined,
storyArcs: comicStoryArcs,
pencillers: comicPencillers,
inkers: comicInkers,
colorists: comicColorists,
letterers: comicLetterers,
coverArtists: comicCoverArtists,
characters: comicCharacters,
teams: comicTeams,
locations: comicLocations,
},
const previous = JSON.parse(snapshot.value) as typeof form
const payload: Record<string, unknown> = {}
for (const field of ROOT_FIELDS) {
if (JSON.stringify(form[field]) !== JSON.stringify(previous[field])) {
payload[field] = form[field]
}
}
const comicMetadata: Record<string, unknown> = {}
for (const [payloadKey, formKey] of Object.entries(COMIC_FIELDS)) {
if (JSON.stringify(form[formKey]) !== JSON.stringify(previous[formKey])) {
comicMetadata[payloadKey] = form[formKey]
}
}
if (Object.keys(comicMetadata).length > 0) {
payload.comicMetadata = comicMetadata
}
if (includeAudioMetadata.value) {
return {
...payload,
audioMetadata: {
narrators,
durationSeconds,
abridged,
},
const audioMetadata: Record<string, unknown> = {}
for (const [payloadKey, formKey] of Object.entries(AUDIO_FIELDS)) {
if (JSON.stringify(form[formKey]) !== JSON.stringify(previous[formKey])) {
audioMetadata[payloadKey] = form[formKey]
}
}
if (Object.keys(audioMetadata).length > 0) {
payload.audioMetadata = audioMetadata
}
}
return payload
@@ -0,0 +1,84 @@
import { computed, ref } from 'vue'
import { api } from '@/lib/api'
import { BOOK_METADATA_LOCK_FIELDS, type BookDetail, type BookMetadataLockField } from '@projectx/types'
function normalizeLockedFields(fields: readonly BookMetadataLockField[]): BookMetadataLockField[] {
const unique = new Set(fields)
return BOOK_METADATA_LOCK_FIELDS.filter((field) => unique.has(field))
}
export function useMetadataLocks() {
const lockedFields = ref<BookMetadataLockField[]>([])
const updatingField = ref<BookMetadataLockField | 'all' | null>(null)
const error = ref<string | null>(null)
const updating = computed(() => updatingField.value !== null)
const lockedFieldSet = computed(() => new Set(lockedFields.value))
const areAllLocked = computed(() => BOOK_METADATA_LOCK_FIELDS.every((field) => lockedFieldSet.value.has(field)))
function load(book: BookDetail) {
lockedFields.value = normalizeLockedFields(book.lockedFields)
error.value = null
}
function isLocked(field: BookMetadataLockField): boolean {
return lockedFieldSet.value.has(field)
}
async function replace(
bookId: number,
nextLockedFields: readonly BookMetadataLockField[],
scope: BookMetadataLockField | 'all',
): Promise<BookDetail | null> {
updatingField.value = scope
error.value = null
try {
const normalized = normalizeLockedFields(nextLockedFields)
const res = await api(`/api/v1/books/${bookId}/metadata-locks`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lockedFields: normalized }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const updated: BookDetail = await res.json()
lockedFields.value = normalizeLockedFields(updated.lockedFields)
return updated
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to update locks'
return null
} finally {
updatingField.value = null
}
}
function isUpdating(field: BookMetadataLockField): boolean {
return updatingField.value === field || updatingField.value === 'all'
}
async function toggle(bookId: number, field: BookMetadataLockField): Promise<BookDetail | null> {
const nextLockedFields = isLocked(field) ? lockedFields.value.filter((lockedField) => lockedField !== field) : [...lockedFields.value, field]
return replace(bookId, nextLockedFields, field)
}
async function lockAll(bookId: number): Promise<BookDetail | null> {
return replace(bookId, BOOK_METADATA_LOCK_FIELDS, 'all')
}
async function unlockAll(bookId: number): Promise<BookDetail | null> {
return replace(bookId, [], 'all')
}
return {
lockedFields,
updating,
error,
areAllLocked,
load,
isLocked,
isUpdating,
replace,
toggle,
lockAll,
unlockAll,
}
}
+6 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, provide, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { BookDetail } from '@projectx/types'
import type { BookDetail, BookMetadataLockField } from '@projectx/types'
import BookDetailLayout from '@/features/book/components/detail/BookDetailLayout.vue'
import DetailsTab from '@/features/book/components/detail/tabs/DetailsTab.vue'
import FilesTab from '@/features/book/components/detail/tabs/FilesTab.vue'
@@ -69,6 +69,10 @@ function onMetadataSaved(updated: BookDetail) {
detail.value = updated
}
function onLocksChanged(lockedFields: BookMetadataLockField[]) {
if (detail.value) detail.value.lockedFields = lockedFields
}
function onCoverChanged(source: 'extracted' | 'custom' | null) {
if (detail.value) detail.value = { ...detail.value, coverSource: source }
}
@@ -82,6 +86,7 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) {
v-else-if="tab === 'edit' && hasPermission('library_edit_metadata')"
:book="detail"
@saved="onMetadataSaved"
@locks-changed="onLocksChanged"
@cover-changed="onCoverChanged"
/>
<FilesTab v-else-if="tab === 'files'" :book="detail" />
@@ -83,6 +83,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
audioMetadata: null,
formatPriority: [],
comicMetadata: null,
lockedFields: [],
collections: [],
...overrides,
}