feat(metadata): add configurable metadata score with filter support

This commit is contained in:
neon
2026-03-13 03:05:51 -06:00
parent 3d8dabf9ae
commit c9221f92be
37 changed files with 932 additions and 17 deletions
@@ -17,12 +17,19 @@ const emit = defineEmits<{
}>()
const MAX_DEPTH = 5
const NUMERIC_FIELDS: RuleField[] = ['seriesIndex', 'publishedYear', 'pageCount', 'rating']
const NUMERIC_FIELDS: RuleField[] = ['seriesIndex', 'publishedYear', 'pageCount', 'rating', 'metadataScore']
const DATE_FIELDS: RuleField[] = ['addedAt']
const NO_VALUE_OPERATORS: RuleOperator[] = ['isEmpty', 'isNotEmpty', 'isMissing', 'isPresent', 'isUnread', 'isInProgress', 'isFinished']
const BETWEEN_OPERATORS: RuleOperator[] = ['between']
const COLLECTION_OPERATORS: RuleOperator[] = ['includesAny', 'includesAll', 'excludesAll']
const SCORE_PRESETS = [
{ label: 'Outstanding', gte: 90 },
{ label: 'Good', gte: 70 },
{ label: 'Fair', gte: 50 },
{ label: 'Poor', lt: 50 },
] as const
const CHIP_TYPEAHEAD_FIELDS: RuleField[] = ['author', 'genre', 'tag', 'collection']
const TEXT_TYPEAHEAD_FIELDS: RuleField[] = ['publisher', 'series', 'language']
@@ -108,7 +115,8 @@ watch(
function isRuleComplete(r: EditableRule): boolean {
if (NO_VALUE_OPERATORS.includes(r.operator)) return true
if (COLLECTION_OPERATORS.includes(r.operator)) return r.valueChips.length > 0
return r.value.trim() !== ''
if (BETWEEN_OPERATORS.includes(r.operator)) return String(r.value).trim() !== '' && String(r.valueTo).trim() !== ''
return String(r.value).trim() !== ''
}
function emitUpdate() {
@@ -206,6 +214,20 @@ function onSubGroupUpdate(index: number, val: GroupRule | undefined) {
emitUpdate()
}
function applyScorePreset(index: number, preset: (typeof SCORE_PRESETS)[number]) {
const node = nodes.value[index]
if (node?.kind !== 'rule') return
if ('lt' in preset) {
node.rule.operator = 'lt'
node.rule.value = String(preset.lt)
} else {
node.rule.operator = 'gte'
node.rule.value = String(preset.gte)
}
node.rule.valueTo = ''
emitUpdate()
}
function valueInputType(field: RuleField, operator: RuleOperator): string {
if (NO_VALUE_OPERATORS.includes(operator)) return 'none'
if (DATE_FIELDS.includes(field)) return operator === 'withinLast' ? 'number' : 'date'
@@ -300,6 +322,47 @@ function showValueToInput(operator: RuleOperator): boolean {
:endpoint="ENDPOINT_BY_FIELD[node.rule.field]!"
@update:model-value="emitUpdate"
/>
<!-- Metadata score: preset range chips + numeric input -->
<template v-else-if="node.rule.field === 'metadataScore' && !NO_VALUE_OPERATORS.includes(node.rule.operator)">
<div class="flex flex-wrap items-center gap-1.5">
<button
v-for="preset in SCORE_PRESETS"
:key="preset.label"
type="button"
class="h-7 px-2.5 rounded-md text-xs border transition-colors"
:class="
('gte' in preset && node.rule.operator === 'gte' && node.rule.value === String(preset.gte)) ||
('lt' in preset && node.rule.operator === 'lt' && node.rule.value === String(preset.lt))
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-foreground border-input hover:bg-muted'
"
@click="applyScorePreset(index, preset)"
>
{{ preset.label }}
</button>
</div>
<input
v-model="node.rule.value"
@input="emitUpdate"
type="number"
min="0"
max="100"
placeholder="0-100"
class="h-9 rounded-md border border-input bg-background text-foreground text-sm px-2 focus:outline-none focus:ring-2 focus:ring-primary w-24"
/>
<template v-if="showValueToInput(node.rule.operator)">
<span class="text-xs text-muted-foreground">to</span>
<input
v-model="node.rule.valueTo"
@input="emitUpdate"
type="number"
min="0"
max="100"
placeholder="100"
class="h-9 rounded-md border border-input bg-background text-foreground text-sm px-2 focus:outline-none focus:ring-2 focus:ring-primary w-24"
/>
</template>
</template>
<!-- Standard input: text, number, date -->
<template v-else-if="!NO_VALUE_OPERATORS.includes(node.rule.operator)">
<input
@@ -39,6 +39,7 @@ const missingBook: BookCard = {
tags: [],
rating: null,
readingProgress: null,
metadataScore: null,
addedAt: '2026-01-01T00:00:00.000Z',
}
@@ -56,6 +57,7 @@ const presentBook: BookCard = {
tags: [],
rating: null,
readingProgress: null,
metadataScore: null,
addedAt: '2026-01-01T00:00:00.000Z',
}
@@ -41,6 +41,7 @@ const missingBook: BookCard = {
tags: [],
rating: null,
readingProgress: null,
metadataScore: null,
addedAt: '2026-01-01T00:00:00.000Z',
}
@@ -58,6 +59,7 @@ const presentBook: BookCard = {
tags: [],
rating: null,
readingProgress: null,
metadataScore: null,
addedAt: '2026-01-01T00:00:00.000Z',
}
@@ -69,6 +69,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
files: [],
folderPath: '/books',
lastWrittenAt: null,
metadataScore: null,
...overrides,
}
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, Download, FolderPlus, Pencil, Star, Trash2, TriangleAlert, X } from 'lucide-vue-next'
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
@@ -10,11 +10,15 @@ import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import type { BookDetail, BookKoboState } from '@projectx/types'
import RecommendedBooksRow from '@/features/book/components/detail/RecommendedBooksRow.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { api } from '@/lib/api'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
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'
import MetadataScoreBreakdown from '@/features/metadata-score/components/MetadataScoreBreakdown.vue'
import { useMetadataScoreWeights } from '@/features/metadata-score/composables/useMetadataScoreWeights'
type FileProgress = {
percentage: number
@@ -52,6 +56,11 @@ const props = defineProps<{ book: BookDetail }>()
const router = useRouter()
const addToCollectionOpen = ref(false)
const scoreBreakdownOpen = ref(false)
const { weights: scoreWeights, fetchWeights } = useMetadataScoreWeights()
onMounted(fetchWeights)
const {
pendingId: deleteBookId,
@@ -240,6 +249,11 @@ function providerLinkStyle(provider: string) {
}
}
function handleEditMetadataFromScore() {
scoreBreakdownOpen.value = false
router.push({ name: 'book-edit', params: { bookId: props.book.id } })
}
function openBook() {
if (!primaryFile.value) return
router.push({
@@ -457,6 +471,15 @@ watch(
<!-- Identity block -->
<div class="flex items-baseline flex-wrap gap-x-2 gap-y-1">
<h1 class="text-2xl font-bold leading-tight">{{ book.title ?? 'Untitled' }}</h1>
<Popover :open="scoreBreakdownOpen" @update:open="(v) => (scoreBreakdownOpen = v)">
<PopoverTrigger as-child>
<MetadataScoreBadge :score="book.metadataScore" />
</PopoverTrigger>
<PopoverContent class="w-72 p-4" align="start">
<p class="text-sm font-semibold mb-3">Metadata Score</p>
<MetadataScoreBreakdown :book="book" :weights="scoreWeights" @edit-metadata="handleEditMetadataFromScore" />
</PopoverContent>
</Popover>
<div v-if="providerLinks.length" class="flex items-center gap-1.5 shrink-0">
<a
v-for="link in providerLinks"
@@ -31,6 +31,7 @@ export const FIELD_LABELS: Record<RuleField, string> = {
readProgress: 'Reading Progress',
description: 'Description',
isbn: 'ISBN',
metadataScore: 'Metadata Score',
}
export const OPERATOR_LABELS: Record<RuleOperator, string> = {
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
score: number | null
}>()
const emit = defineEmits<{
click: []
}>()
function handleClick() {
emit('click')
}
const label = computed(() => {
if (props.score === null) return null
return `${props.score}%`
})
const colorClass = computed(() => {
const s = props.score
if (s === null) return 'bg-muted text-muted-foreground'
if (s >= 90) return 'bg-green-500/15 text-green-600 dark:text-green-400'
if (s >= 70) return 'bg-lime-500/15 text-lime-600 dark:text-lime-400'
if (s >= 50) return 'bg-amber-500/15 text-amber-600 dark:text-amber-400'
return 'bg-red-500/15 text-red-600 dark:text-red-400'
})
</script>
<template>
<button
v-if="label !== null"
type="button"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium cursor-pointer transition-opacity hover:opacity-80"
:class="colorClass"
@click="handleClick"
>
{{ label }}
</button>
</template>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Check, Minus } from 'lucide-vue-next'
import type { BookDetail, MetadataScoreWeights } from '@projectx/types'
import { METADATA_SCORE_FIELDS, METADATA_SCORE_GROUP_LABELS, type MetadataScoreField, type MetadataScoreGroup } from '@projectx/types'
const props = defineProps<{
book: BookDetail
weights: MetadataScoreWeights
}>()
const emit = defineEmits<{
editMetadata: []
}>()
function handleEditMetadata() {
emit('editMetadata')
}
function isFieldFilled(field: MetadataScoreField): boolean {
const b = props.book
switch (field) {
case 'title':
return !!b.title?.trim()
case 'subtitle':
return !!b.subtitle?.trim()
case 'description':
return !!b.description?.trim()
case 'coverSource':
return b.coverSource != null
case 'authors':
return b.authors.length > 0
case 'genres':
return b.genres.length > 0
case 'tags':
return b.tags.length > 0
case 'isbn13':
return !!b.isbn13?.trim()
case 'isbn10':
return !!b.isbn10?.trim()
case 'publisher':
return !!b.publisher?.trim()
case 'publishedYear':
return b.publishedYear != null && b.publishedYear > 0
case 'language':
return !!b.language?.trim()
case 'pageCount':
return b.pageCount != null && b.pageCount > 0
case 'rating':
return b.rating != null && b.rating > 0
case 'seriesName':
return !!b.seriesName?.trim()
case 'seriesIndex':
return b.seriesIndex != null && b.seriesIndex > 0
case 'googleBooksId':
return !!b.providerIds.google?.trim()
case 'goodreadsId':
return !!b.providerIds.goodreads?.trim()
case 'amazonId':
return !!b.providerIds.amazon?.trim()
case 'hardcoverId':
return !!b.providerIds.hardcover?.trim()
case 'openLibraryId':
return !!b.providerIds.openLibrary?.trim()
case 'itunesId':
return !!b.providerIds.itunes?.trim()
default:
return false
}
}
type FieldEntry = {
field: MetadataScoreField
label: string
weight: number
filled: boolean
}
type GroupEntry = {
group: MetadataScoreGroup
label: string
fields: FieldEntry[]
}
const groups = computed<GroupEntry[]>(() => {
const groupOrder: MetadataScoreGroup[] = ['core', 'publishing', 'classification', 'enrichment', 'providers']
const map = new Map<MetadataScoreGroup, FieldEntry[]>()
for (const [field, meta] of Object.entries(METADATA_SCORE_FIELDS) as [MetadataScoreField, (typeof METADATA_SCORE_FIELDS)[MetadataScoreField]][]) {
const weight = props.weights[field]
if (weight === 0) continue
const entry: FieldEntry = { field, label: meta.label, weight, filled: isFieldFilled(field) }
const list = map.get(meta.group) ?? []
list.push(entry)
map.set(meta.group, list)
}
return groupOrder
.filter((g) => map.has(g))
.map((g) => ({
group: g,
label: METADATA_SCORE_GROUP_LABELS[g],
fields: map.get(g)!,
}))
})
const missingCount = computed(() => groups.value.flatMap((g) => g.fields).filter((f) => !f.filled).length)
</script>
<template>
<div class="space-y-4 py-1">
<div v-for="group in groups" :key="group.group">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1.5">{{ group.label }}</p>
<div class="space-y-1">
<div v-for="entry in group.fields" :key="entry.field" class="flex items-center justify-between gap-2 text-sm">
<div class="flex items-center gap-1.5 min-w-0">
<Check v-if="entry.filled" class="size-3.5 text-green-500 shrink-0" />
<Minus v-else class="size-3.5 text-muted-foreground/50 shrink-0" />
<span :class="entry.filled ? 'text-foreground' : 'text-muted-foreground'">{{ entry.label }}</span>
</div>
<span class="text-xs text-muted-foreground shrink-0">{{ entry.weight }}</span>
</div>
</div>
</div>
<div v-if="missingCount > 0" class="pt-1 border-t border-border/50">
<button type="button" class="text-xs text-primary hover:underline" @click="handleEditMetadata">
{{ missingCount }} field{{ missingCount === 1 ? '' : 's' }} missing - edit metadata
</button>
</div>
</div>
</template>
@@ -0,0 +1,28 @@
import { ref } from 'vue'
import type { MetadataScoreWeights } from '@projectx/types'
import { DEFAULT_METADATA_SCORE_WEIGHTS } from '@projectx/types'
import { api } from '@/lib/api'
const weights = ref<MetadataScoreWeights>({ ...DEFAULT_METADATA_SCORE_WEIGHTS })
let fetchPromise: Promise<void> | null = null
export function useMetadataScoreWeights() {
function fetchWeights() {
if (fetchPromise) return fetchPromise
fetchPromise = api('/api/v1/metadata-score/weights')
.then((res) => {
if (res.ok)
return res.json().then((data: MetadataScoreWeights) => {
weights.value = data
})
})
.catch(() => undefined)
return fetchPromise
}
function resetFetchCache() {
fetchPromise = null
}
return { weights, fetchWeights, resetFetchCache }
}
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { onMounted, reactive, ref, computed } from 'vue'
import { RefreshCw, Save } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import type { MetadataScoreWeights, MetadataScoreField } from '@projectx/types'
import { DEFAULT_METADATA_SCORE_WEIGHTS, METADATA_SCORE_FIELDS, METADATA_SCORE_GROUP_LABELS, type MetadataScoreGroup } from '@projectx/types'
import { api } from '@/lib/api'
import { useMetadataScoreWeights } from '@/features/metadata-score/composables/useMetadataScoreWeights'
const { resetFetchCache } = useMetadataScoreWeights()
const weights = reactive<MetadataScoreWeights>({ ...DEFAULT_METADATA_SCORE_WEIGHTS })
const saving = ref(false)
const recalculating = ref(false)
onMounted(async () => {
const res = await api('/api/v1/metadata-score/weights')
if (res.ok) {
const data: MetadataScoreWeights = await res.json()
Object.assign(weights, data)
}
})
const totalWeight = computed(() => Object.values(weights).reduce((s, w) => s + (w ?? 0), 0))
const groupOrder: MetadataScoreGroup[] = ['core', 'publishing', 'classification', 'enrichment', 'providers']
type FieldEntry = { field: MetadataScoreField; label: string }
type GroupEntry = { group: MetadataScoreGroup; label: string; fields: FieldEntry[] }
const groups = computed<GroupEntry[]>(() => {
const map = new Map<MetadataScoreGroup, FieldEntry[]>()
for (const [field, meta] of Object.entries(METADATA_SCORE_FIELDS) as [MetadataScoreField, (typeof METADATA_SCORE_FIELDS)[MetadataScoreField]][]) {
const list = map.get(meta.group) ?? []
list.push({ field, label: meta.label })
map.set(meta.group, list)
}
return groupOrder.filter((g) => map.has(g)).map((g) => ({ group: g, label: METADATA_SCORE_GROUP_LABELS[g], fields: map.get(g)! }))
})
function groupTotal(entry: GroupEntry): number {
return entry.fields.reduce((s, f) => s + (weights[f.field] ?? 0), 0)
}
async function saveWeights() {
if (saving.value) return
saving.value = true
try {
const res = await api('/api/v1/metadata-score/weights', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(weights),
})
if (res.ok) {
resetFetchCache()
toast.success('Score weights saved. Recalculating library scores...')
} else {
toast.error('Failed to save score weights')
}
} catch {
toast.error('Failed to save score weights')
} finally {
saving.value = false
}
}
async function recalculateAll() {
if (recalculating.value) return
recalculating.value = true
try {
const res = await api('/api/v1/metadata-score/recalculate', { method: 'POST' })
if (res.ok) {
toast.success('Score recalculation started in the background')
} else {
toast.error('Recalculation failed')
}
} catch {
toast.error('Recalculation failed')
} finally {
recalculating.value = false
}
}
</script>
<template>
<div class="space-y-6">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-sm font-semibold">Metadata Score Weights</h2>
<p class="text-xs text-muted-foreground mt-0.5">
Assign a weight to each field. Fields with weight 0 are excluded from the score. Total max weight:
<span class="font-medium text-foreground">{{ totalWeight }}</span
>.
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
type="button"
class="flex items-center gap-1.5 h-8 px-3 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors disabled:opacity-50"
:disabled="recalculating"
@click="recalculateAll"
>
<RefreshCw class="size-3.5" :class="{ 'animate-spin': recalculating }" />
Recalculate All
</button>
<button
type="button"
class="flex items-center gap-1.5 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="saving"
@click="saveWeights"
>
<Save class="size-3.5" />
Save
</button>
</div>
</div>
<div class="space-y-6">
<div v-for="group in groups" :key="group.group">
<div class="flex items-baseline justify-between mb-3">
<h3 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide">{{ group.label }}</h3>
<span class="text-xs text-muted-foreground">subtotal: {{ groupTotal(group) }}</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div v-for="entry in group.fields" :key="entry.field" class="flex items-center gap-2">
<label :for="`weight-${entry.field}`" class="text-sm text-foreground flex-1 min-w-0 truncate">
{{ entry.label }}
</label>
<input
:id="`weight-${entry.field}`"
v-model.number="weights[entry.field]"
type="number"
min="0"
class="w-16 h-8 px-2 text-sm text-right rounded-md border border-input bg-background focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -9,6 +9,7 @@ import { useProviderConfig } from './composables/useProviderConfig'
import { useMetadataPreferences } from './composables/useMetadataPreferences'
import { Info } from 'lucide-vue-next'
import SettingsPageHeader from '../SettingsPageHeader.vue'
import MetadataScoreWeightsSettings from '../MetadataScoreWeightsSettings.vue'
const { config, statuses, saving: savingProviders, fetchConfig, saveConfig } = useProviderConfig()
const { globalPrefs, libraryPrefs, savingGlobal, savingField, fetchGlobal, saveGlobal, fetchLibrary, saveFieldOverride, resetLibrary } =
@@ -92,5 +93,10 @@ async function onResetLibrary(libraryId: number) {
</div>
</div>
</section>
<!-- Metadata Score Weights -->
<section class="space-y-4 pt-4 border-t border-border">
<MetadataScoreWeightsSettings />
</section>
</div>
</template>