mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): add configurable metadata score with filter support
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user