Modal focus/keyboard polish, Camie-in-autocomplete, re-extract self-resume #82

Merged
bvandeusen merged 4 commits from dev into main 2026-06-07 12:17:43 -04:00
6 changed files with 371 additions and 70 deletions
+35 -3
View File
@@ -12,6 +12,7 @@ the one-and-done GS/IR migration tooling.)
from __future__ import annotations from __future__ import annotations
import logging import logging
import time
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -723,7 +724,13 @@ def _reextract_archive_to_post(
sidecar_path.unlink(missing_ok=True) sidecar_path.unlink(missing_ok=True)
def reextract_archive_attachments(session: Session, *, images_root: Path) -> dict: def reextract_archive_attachments(
session: Session,
*,
images_root: Path,
time_budget_seconds: float | None = None,
after_id: int = 0,
) -> dict:
"""Re-process existing PostAttachments that are ACTUALLY archives but were """Re-process existing PostAttachments that are ACTUALLY archives but were
filed opaquely before #713 part 1 (extension-only is_archive missed mangled / filed opaquely before #713 part 1 (extension-only is_archive missed mangled /
extension-less Patreon attachment names). For each: extract the members, extension-less Patreon attachment names). For each: extract the members,
@@ -731,6 +738,14 @@ def reextract_archive_attachments(session: Session, *, images_root: Path) -> dic
Idempotent — members dedupe by sha256, the archive dedupes by sha — so it's Idempotent — members dedupe by sha256, the archive dedupes by sha — so it's
safe to run repeatedly. Returns a summary dict for task_run.metadata. safe to run repeatedly. Returns a summary dict for task_run.metadata.
Time-boxed + resumable: scans PostAttachments in ascending id order starting
after ``after_id``. When ``time_budget_seconds`` elapses, stops and reports
``partial=True`` + ``resume_after_id`` (the last scanned id) so the task can
re-enqueue itself and continue — a large archive back-catalog can't run the
task into the Celery time limit or hog the maintenance lane. A bare re-run
(after_id=0) would never advance because an already-extracted archive is
still an archive on disk, so the cursor is what guarantees forward progress.
""" """
from ..models import ImportSettings, Post, PostAttachment, Source from ..models import ImportSettings, Post, PostAttachment, Source
from ..tasks.ml import tag_and_embed from ..tasks.ml import tag_and_embed
@@ -742,7 +757,7 @@ def reextract_archive_attachments(session: Session, *, images_root: Path) -> dic
summary = { summary = {
"scanned": 0, "archives": 0, "members_imported": 0, "scanned": 0, "archives": 0, "members_imported": 0,
"posts_touched": 0, "skipped_no_post": 0, "skipped_no_artist": 0, "posts_touched": 0, "skipped_no_post": 0, "skipped_no_artist": 0,
"errors": 0, "errors": 0, "partial": False, "resume_after_id": after_id,
} }
settings = ImportSettings.load_sync(session) settings = ImportSettings.load_sync(session)
importer = Importer( importer = Importer(
@@ -751,11 +766,15 @@ def reextract_archive_attachments(session: Session, *, images_root: Path) -> dic
) )
attachments = session.execute( attachments = session.execute(
select(PostAttachment).order_by(PostAttachment.id) select(PostAttachment)
.where(PostAttachment.id > after_id)
.order_by(PostAttachment.id)
).scalars().all() ).scalars().all()
enqueue_ids: list[int] = [] enqueue_ids: list[int] = []
start = time.monotonic()
for att in attachments: for att in attachments:
summary["scanned"] += 1 summary["scanned"] += 1
summary["resume_after_id"] = att.id
stored = Path(att.path) stored = Path(att.path)
try: try:
if not stored.is_file() or not is_archive(stored): if not stored.is_file() or not is_archive(stored):
@@ -795,6 +814,19 @@ def reextract_archive_attachments(session: Session, *, images_root: Path) -> dic
summary["posts_touched"] += 1 summary["posts_touched"] += 1
enqueue_ids.extend(ids) enqueue_ids.extend(ids)
# Time-box the chunk. resume_after_id already points at this attachment,
# so the next run starts strictly after it. Checked after the commit so a
# half-extracted archive never straddles the boundary.
if (
time_budget_seconds is not None
and time.monotonic() - start >= time_budget_seconds
):
summary["partial"] = True
break
else:
# Loop ran to exhaustion — nothing left to resume.
summary["partial"] = False
# Thumbnails + ML for the newly-imported members (best-effort; off the # Thumbnails + ML for the newly-imported members (best-effort; off the
# critical path — a Redis hiccup must not fail the whole re-extract). # critical path — a Redis hiccup must not fail the whole re-extract).
for img_id in enqueue_ids: for img_id in enqueue_ids:
+26 -3
View File
@@ -57,6 +57,14 @@ def bulk_delete_images_task(self, *, image_ids: list[int]) -> dict:
) )
# Time-box one chunk well under the soft limit so a large archive back-catalog
# can't run the task into the Celery time limit (or hog the maintenance_long
# lane). The task re-enqueues itself with the resume cursor until the scan is
# exhausted — mirrors normalize_tags_task (operator-asked 2026-06-07: reasonable
# timeout, then re-queue so other work keeps flowing).
_REEXTRACT_CHUNK_SECONDS = 600
@celery.task( @celery.task(
name="backend.app.tasks.admin.reextract_archive_attachments_task", name="backend.app.tasks.admin.reextract_archive_attachments_task",
bind=True, bind=True,
@@ -64,15 +72,30 @@ def bulk_delete_images_task(self, *, image_ids: list[int]) -> dict:
retry_backoff=15, retry_backoff_max=180, max_retries=1, retry_backoff=15, retry_backoff_max=180, max_retries=1,
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
) )
def reextract_archive_attachments_task(self) -> dict: def reextract_archive_attachments_task(self, after_id: int = 0) -> dict:
"""Wraps cleanup_service.reextract_archive_attachments (#713 part 2): """Wraps cleanup_service.reextract_archive_attachments (#713 part 2):
re-extract PostAttachments that are actually archives but were filed re-extract PostAttachments that are actually archives but were filed
opaquely before the magic-byte gate, and link their members to the post.""" opaquely before the magic-byte gate, and link their members to the post.
Time-boxed + self-resuming: scans attachments after ``after_id`` and, on a
chunk cut, re-enqueues from where it stopped so a big backlog finishes across
chunks instead of dying at the soft limit."""
SessionLocal = _sync_session_factory() SessionLocal = _sync_session_factory()
with SessionLocal() as session: with SessionLocal() as session:
return cleanup_service.reextract_archive_attachments( summary = cleanup_service.reextract_archive_attachments(
session, images_root=IMAGES_ROOT, session, images_root=IMAGES_ROOT,
time_budget_seconds=_REEXTRACT_CHUNK_SECONDS, after_id=after_id,
) )
# More attachments past this chunk's cursor — continue in the next.
if summary.get("partial") and summary.get("resume_after_id", 0) > after_id:
log.info(
"reextract chunk done (%d scanned, %d archives, resume after id %s) "
"— re-enqueuing to continue",
summary.get("scanned", 0), summary.get("archives", 0),
summary["resume_after_id"],
)
reextract_archive_attachments_task.delay(summary["resume_after_id"])
return summary
# Time-box one chunk well under the soft limit so a large back-catalog (the # Time-box one chunk well under the soft limit so a large back-catalog (the
+64 -16
View File
@@ -2,22 +2,37 @@
<v-card> <v-card>
<v-card-title>Pick a fandom</v-card-title> <v-card-title>Pick a fandom</v-card-title>
<v-card-text> <v-card-text>
<!-- A2/A3 (2026-06-07): autofocus so the operator types immediately, and <!-- Keyboard flow (operator-specified 2026-06-07):
picking a fandom (keyboard: type arrow Enter, or a click) confirms 1. Focus lands here (driven by the dialog's @after-enter calling the
in one step selecting IS the decision in this dialog. --> exposed focusSearch — `autofocus` is unreliable inside a v-dialog
because the focus-trap activates after mount and steals it).
2. Tab moves to the "new fandom" field below.
3. Selecting a fandom (click, or arrow+Enter to pick from the menu)
leaves it in the field; a SECOND Enter (menu closed) accepts it.
We bind v-model:menu so the Enter handler can tell "pick from the open
menu" (let Vuetify handle) apart from "accept the chosen value". -->
<v-autocomplete <v-autocomplete
ref="fandomRef"
v-model="selectedId" v-model="selectedId"
v-model:menu="menuOpen"
:items="store.fandomCache" :items="store.fandomCache"
:item-title="(f) => f.name" :item-title="(f) => f.name"
:item-value="(f) => f.id" :item-value="(f) => f.id"
label="Fandom" clearable density="compact" label="Fandom" clearable density="compact"
autofocus @keydown.enter="onSearchEnter"
@update:model-value="onSelect" @keydown.tab="onSearchTab"
/> />
<v-divider class="my-3" /> <v-divider class="my-3" />
<p class="text-caption mb-2">Or create a new fandom:</p> <p class="text-caption mb-2">Or create a new fandom:</p>
<div class="d-flex" style="gap: 8px;"> <div class="d-flex" style="gap: 8px;">
<v-text-field v-model="newName" placeholder="New fandom name" density="compact" hide-details /> <!-- Enter creates; on success focus returns to the dropdown (onCreate),
so the operator can immediately Enter-to-accept the new fandom. -->
<v-text-field
ref="newNameRef"
v-model="newName" placeholder="New fandom name"
density="compact" hide-details
@keydown.enter.prevent="onCreate"
/>
<v-btn :disabled="!newName.trim()" rounded="pill" @click="onCreate">Create</v-btn> <v-btn :disabled="!newName.trim()" rounded="pill" @click="onCreate">Create</v-btn>
</div> </div>
</v-card-text> </v-card-text>
@@ -34,7 +49,7 @@
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { nextTick, onMounted, ref } from 'vue'
import { useTagStore } from '../../stores/tags.js' import { useTagStore } from '../../stores/tags.js'
const emit = defineEmits(['confirm', 'cancel']) const emit = defineEmits(['confirm', 'cancel'])
@@ -42,28 +57,61 @@ const store = useTagStore()
const selectedId = ref(null) const selectedId = ref(null)
const newName = ref('') const newName = ref('')
const menuOpen = ref(false)
const fandomRef = ref(null)
const newNameRef = ref(null)
// Always refresh on open so the list reflects fandoms created elsewhere (#712). // Always refresh on open so the list reflects fandoms created elsewhere (#712).
onMounted(() => store.loadFandoms()) onMounted(() => store.loadFandoms())
async function onCreate() { // Exposed so the parent dialog can focus the search field on @after-enter —
const f = await store.createFandom(newName.value.trim()) // the reliable point to grab focus (the dialog transition + focus-trap are done).
function focusSearch () {
nextTick(() => {
fandomRef.value?.focus?.()
// Keep the menu closed after a programmatic focus so the very next Enter
// accepts the value instead of being swallowed as a menu interaction.
menuOpen.value = false
})
}
defineExpose({ focusSearch })
async function onCreate () {
const name = newName.value.trim()
if (!name) return
const f = await store.createFandom(name)
selectedId.value = f.id selectedId.value = f.id
newName.value = '' newName.value = ''
// The created fandom is now the selection; hand focus back to the dropdown
// so a single Enter accepts it (operator-specified flow 2026-06-07).
focusSearch()
} }
function onConfirm() {
function onConfirm () {
const f = store.fandomCache.find(x => x.id === selectedId.value) const f = store.fandomCache.find(x => x.id === selectedId.value)
if (f) emit('confirm', f) if (f) emit('confirm', f)
} }
// Picking a fandom (keyboard Enter or click) confirms immediately. Ignore the
// clear action (null) so clearing the field doesn't fire a confirm. // Enter on the search field. When the menu is open Vuetify is picking the
function onSelect(id) { // highlighted item (it sets selectedId + closes the menu) — don't also accept
if (id == null) return // on that keystroke. With the menu closed and a value chosen, Enter accepts.
onConfirm() function onSearchEnter () {
if (menuOpen.value) return
if (selectedId.value != null) onConfirm()
} }
// Tab from the search field goes to the "new fandom" text field (operator-
// specified). Shift+Tab keeps normal reverse traversal.
function onSearchTab (e) {
if (e.shiftKey) return
e.preventDefault()
menuOpen.value = false
nextTick(() => newNameRef.value?.focus?.())
}
// Create the character with no fandom. Emits null so the caller knows this // Create the character with no fandom. Emits null so the caller knows this
// was a deliberate "unassigned", not a cancel. // was a deliberate "unassigned", not a cancel.
function onNoFandom() { function onNoFandom () {
emit('confirm', null) emit('confirm', null)
} }
</script> </script>
+156 -47
View File
@@ -12,44 +12,83 @@
@keydown.esc="$emit('cancel')" @keydown.esc="$emit('cancel')"
/> />
<v-list <v-list
v-if="hits.length || allowCreate" v-if="rows.length"
ref="listRef" ref="listRef"
density="compact" class="fc-tag-autocomplete__list" density="compact" class="fc-tag-autocomplete__list"
> >
<v-list-item <template v-for="(row, idx) in rows" :key="row.key">
v-for="(h, idx) in hits" :key="h.id" <!-- Existing tag match (server autocomplete). -->
:active="idx === highlight" @click="onPick(h)" <v-list-item
> v-if="row.type === 'hit'"
<template #prepend> :active="idx === highlight" @click="onPickRow(row)"
<v-icon size="small" :color="store.colorFor(h.kind)"> >
{{ iconFor(h.kind) }} <template #prepend>
</v-icon> <v-icon size="small" :color="store.colorFor(row.hit.kind)">
</template> {{ iconFor(row.hit.kind) }}
<v-list-item-title> </v-icon>
{{ h.name }} </template>
<span v-if="h.fandom_name" class="text-caption"> {{ h.fandom_name }}</span> <v-list-item-title>
</v-list-item-title> {{ row.hit.name }}
<template #append> <span v-if="row.hit.fandom_name" class="text-caption"> {{ row.hit.fandom_name }}</span>
<span class="text-caption">{{ h.kind }}</span> </v-list-item-title>
</template> <template #append>
</v-list-item> <span class="text-caption">{{ row.hit.kind }}</span>
<v-list-item </template>
v-if="allowCreate" :active="highlight === hits.length" </v-list-item>
@click="onCreate"
> <!-- ML suggestion for THIS image that matches the typed query. Picking
<template #prepend> it routes through the same accept path as the Suggestions panel, so
<v-icon size="small" :color="store.colorFor(parsedKind)"> it's recorded + drops out of the panel (operator-asked 2026-06-07). -->
{{ iconFor(parsedKind) }} <v-list-item
</v-icon> v-else-if="row.type === 'suggestion'"
</template> :active="idx === highlight" @click="onPickRow(row)"
<v-list-item-title> class="fc-tag-autocomplete__sugg"
Create "{{ parsedName }}" as {{ parsedKind }} >
</v-list-item-title> <template #prepend>
</v-list-item> <v-icon size="small" :color="store.colorFor(row.sugg.category)">
{{ iconFor(row.sugg.category) }}
</v-icon>
</template>
<v-list-item-title>
{{ row.sugg.display_name }}
<span v-if="row.sugg.creates_new_tag" class="text-caption">— new</span>
</v-list-item-title>
<template #append>
<span class="fc-tag-autocomplete__sugg-tag">
<v-icon size="x-small">mdi-auto-fix</v-icon>
{{ scorePct(row.sugg) }}
</span>
</template>
</v-list-item>
<!-- Create-new row. -->
<v-list-item
v-else
:active="idx === highlight" @click="onPickRow(row)"
>
<template #prepend>
<v-icon size="small" :color="store.colorFor(parsedKind)">
{{ iconFor(parsedKind) }}
</v-icon>
</template>
<v-list-item-title>
Create "{{ parsedName }}" as {{ parsedKind }}
</v-list-item-title>
</v-list-item>
</template>
</v-list> </v-list>
<v-dialog v-model="fandomDialog" max-width="480"> <!-- @after-enter is the reliable moment to focus the picker: the dialog's
<FandomPicker @confirm="onFandomChosen" @cancel="fandomDialog = false" /> open transition + focus-trap have settled, so focusSearch lands in the
fandom field instead of fighting the trap (operator-flagged 2026-06-07). -->
<v-dialog
v-model="fandomDialog" max-width="480"
@after-enter="fandomPickerRef?.focusSearch?.()"
>
<FandomPicker
ref="fandomPickerRef"
@confirm="onFandomChosen" @cancel="onFandomCancel"
/>
</v-dialog> </v-dialog>
</div> </div>
</template> </template>
@@ -57,10 +96,15 @@
<script setup> <script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useTagStore } from '../../stores/tags.js' import { useTagStore } from '../../stores/tags.js'
import { useSuggestionsStore } from '../../stores/suggestions.js'
import FandomPicker from './FandomPicker.vue' import FandomPicker from './FandomPicker.vue'
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel']) const emit = defineEmits(['pick-existing', 'pick-new', 'accept-suggestion', 'cancel'])
const store = useTagStore() const store = useTagStore()
// The image's ML (Camie) suggestions, surfaced inline in this dropdown so the
// operator can pick a suggestion while typing instead of hunting for it in the
// Suggestions panel below (operator-asked 2026-06-07).
const suggestions = useSuggestionsStore()
// Autofocus on modal open so the operator can type the moment the view // Autofocus on modal open so the operator can type the moment the view
// modal renders, no extra click required (operator-asked 2026-06-01). // modal renders, no extra click required (operator-asked 2026-06-01).
@@ -74,10 +118,11 @@ const store = useTagStore()
// the modal's stacked-layout breakpoint (ImageViewer.vue). matchMedia is // the modal's stacked-layout breakpoint (ImageViewer.vue). matchMedia is
// safe on plain HTTP (not a secure-context API). // safe on plain HTTP (not a secure-context API).
const inputRef = ref(null) const inputRef = ref(null)
onMounted(() => { function focusInput () {
if (window.matchMedia?.('(max-width: 900px)')?.matches) return if (window.matchMedia?.('(max-width: 900px)')?.matches) return
nextTick(() => inputRef.value?.focus?.()) nextTick(() => inputRef.value?.focus?.())
}) }
onMounted(focusInput)
// Single text input; no kind dropdown. Client-side mirror of the // Single text input; no kind dropdown. Client-side mirror of the
// backend's parse_kind_prefix lives below — kept in sync with // backend's parse_kind_prefix lives below — kept in sync with
@@ -89,6 +134,7 @@ const hits = ref([])
const highlight = ref(0) const highlight = ref(0)
const listRef = ref(null) const listRef = ref(null)
const fandomDialog = ref(false) const fandomDialog = ref(false)
const fandomPickerRef = ref(null)
let pendingNewName = null let pendingNewName = null
const KNOWN_KINDS = new Set([ const KNOWN_KINDS = new Set([
@@ -136,8 +182,42 @@ const allowCreate = computed(() => {
) )
}) })
function scorePct (s) { return `${Math.round(s.score * 100)}%` }
// This image's suggestions that match the typed query, minus any the server
// autocomplete already returned (same name+kind) so a tag never shows twice.
const suggestionHits = computed(() => {
const q = parsedName.value.toLowerCase()
if (!q) return []
const seen = new Set(hits.value.map(h => `${h.kind}:${h.name.toLowerCase()}`))
const out = []
for (const list of Object.values(suggestions.byCategory)) {
for (const s of list || []) {
const key = `${s.category}:${s.display_name.toLowerCase()}`
if (!s.display_name.toLowerCase().includes(q)) continue
if (seen.has(key)) continue
seen.add(key)
out.push(s)
}
}
// Best matches first; cap so the dropdown stays scannable.
out.sort((a, b) => b.score - a.score)
return out.slice(0, 6)
})
// One ordered list backing both the rendered dropdown and keyboard nav, so the
// highlight index maps 1:1 to a row regardless of which section it's in.
const rows = computed(() => {
const r = hits.value.map(h => ({ type: 'hit', key: `h${h.id}`, hit: h }))
for (const s of suggestionHits.value) {
r.push({ type: 'suggestion', key: `s${s.category}:${s.display_name}`, sugg: s })
}
if (allowCreate.value) r.push({ type: 'create', key: 'create' })
return r
})
function moveHighlight (delta) { function moveHighlight (delta) {
const total = hits.value.length + (allowCreate.value ? 1 : 0) const total = rows.value.length
if (total === 0) return if (total === 0) return
highlight.value = (highlight.value + delta + total) % total highlight.value = (highlight.value + delta + total) % total
// Keep the highlighted row visible — the list is capped at 240px and arrowing // Keep the highlighted row visible — the list is capped at 240px and arrowing
@@ -150,7 +230,18 @@ function moveHighlight (delta) {
}) })
} }
function onPick (hit) { emit('pick-existing', hit); reset() } // Dispatch a chosen dropdown row by its type.
function onPickRow (row) {
if (row.type === 'hit') { emit('pick-existing', row.hit); reset() }
else if (row.type === 'suggestion') { onPickSuggestion(row.sugg) }
else { onCreate() }
}
// Picking an ML suggestion hands it to the parent, which runs the same accept
// flow as the Suggestions panel (creates the tag if raw, records acceptance,
// drops it from the panel). reset() clears the query; the panel-drop makes it
// fall out of suggestionHits reactively.
function onPickSuggestion (s) { emit('accept-suggestion', s); reset() }
function onCreate () { function onCreate () {
const name = parsedName.value const name = parsedName.value
@@ -178,21 +269,29 @@ function onFandomChosen (fandom) {
}) })
pendingNewName = null pendingNewName = null
reset() reset()
// Return focus to the tag input so the keyboard flow continues straight into
// the next tag instead of dropping to <body> (operator-flagged 2026-06-07).
focusInput()
}
// Cancelling the fandom dialog drops the pending character and hands focus back
// to the tag input, same as a successful pick.
function onFandomCancel () {
fandomDialog.value = false
pendingNewName = null
focusInput()
} }
function onEnter () { function onEnter () {
if (highlight.value < hits.value.length) { const row = rows.value[highlight.value]
onPick(hits.value[highlight.value]) if (row) onPickRow(row)
} else if (allowCreate.value) {
onCreate()
}
} }
// B5 (2026-06-07): when the suggestion list is open, Tab accepts the // B5 (2026-06-07): when the dropdown is open, Tab accepts the highlighted row
// highlighted row (standard autocomplete convention) instead of leaving the // (standard autocomplete convention) instead of leaving the field. With the
// field. With the list closed it falls through to normal focus traversal. // list closed it falls through to normal focus traversal.
function onTab (e) { function onTab (e) {
if (hits.value.length || allowCreate.value) { if (rows.value.length) {
e.preventDefault() e.preventDefault()
onEnter() onEnter()
} }
@@ -210,4 +309,14 @@ function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
border: 1px solid rgb(var(--v-theme-surface-light)); border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 6px; border-radius: 6px;
} }
/* Mark ML-suggestion rows so they read as distinct from typed/known matches. */
.fc-tag-autocomplete__sugg {
border-left: 2px solid rgb(var(--v-theme-accent), 0.5);
}
.fc-tag-autocomplete__sugg-tag {
display: inline-flex; align-items: center; gap: 2px;
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
color: rgb(var(--v-theme-accent));
}
</style> </style>
@@ -14,6 +14,7 @@
<TagAutocomplete <TagAutocomplete
@pick-existing="onPickExisting" @pick-new="onPickNew" @pick-existing="onPickExisting" @pick-new="onPickNew"
@accept-suggestion="onAcceptSuggestion"
/> />
<v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable> <v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable>
@@ -45,6 +46,7 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useModalStore } from '../../stores/modal.js' import { useModalStore } from '../../stores/modal.js'
import { useSuggestionsStore } from '../../stores/suggestions.js'
import TagChip from './TagChip.vue' import TagChip from './TagChip.vue'
import TagAutocomplete from './TagAutocomplete.vue' import TagAutocomplete from './TagAutocomplete.vue'
import SuggestionsPanel from './SuggestionsPanel.vue' import SuggestionsPanel from './SuggestionsPanel.vue'
@@ -52,6 +54,7 @@ import TagRenameDialog from './TagRenameDialog.vue'
import FandomSetDialog from './FandomSetDialog.vue' import FandomSetDialog from './FandomSetDialog.vue'
const modal = useModalStore() const modal = useModalStore()
const suggestions = useSuggestionsStore()
const errorMsg = ref(null) const errorMsg = ref(null)
async function onRemove(tagId) { async function onRemove(tagId) {
@@ -69,6 +72,16 @@ async function onPickNew(payload) {
try { await modal.createAndAdd(payload) } try { await modal.createAndAdd(payload) }
catch (e) { errorMsg.value = e.message } catch (e) { errorMsg.value = e.message }
} }
// A suggestion picked from the autocomplete dropdown runs the SAME path as the
// Suggestions panel's Accept: the store creates the tag if it's raw, records the
// acceptance, and drops it from the panel; then we refresh the chip rail.
async function onAcceptSuggestion(s) {
errorMsg.value = null
try {
await suggestions.accept(s)
await modal.reloadTags()
} catch (e) { errorMsg.value = e.message }
}
const renameDialog = ref(false) const renameDialog = ref(false)
const renameTarget = ref(null) const renameTarget = ref(null)
+77 -1
View File
@@ -6,7 +6,7 @@ import io
import zipfile import zipfile
import pytest import pytest
from PIL import Image from PIL import Image, ImageDraw
from sqlalchemy import select from sqlalchemy import select
from backend.app.models import ( from backend.app.models import (
@@ -28,6 +28,18 @@ def _jpeg(color, size=256):
return buf.getvalue() return buf.getvalue()
def _patterned_jpeg(idx, size=256):
"""Structurally distinct per idx so the members don't phash-collapse — solid
colors collide to distance 0 and the second would dedupe away (the two-axis
dedup gotcha). A rectangle at an idx-derived position keeps phashes apart."""
img = Image.new("RGB", (size, size), "white")
x = 20 + idx * 60
ImageDraw.Draw(img).rectangle([x, x, x + 70, x + 70], fill="black")
buf = io.BytesIO()
img.save(buf, "JPEG")
return buf.getvalue()
def test_reextract_links_archive_members_to_post(db_sync, tmp_path, monkeypatch): def test_reextract_links_archive_members_to_post(db_sync, tmp_path, monkeypatch):
from backend.app.tasks import ml as ml_mod from backend.app.tasks import ml as ml_mod
from backend.app.tasks import thumbnail as thumb_mod from backend.app.tasks import thumbnail as thumb_mod
@@ -89,3 +101,67 @@ def test_reextract_links_archive_members_to_post(db_sync, tmp_path, monkeypatch)
) )
assert again["members_imported"] == 0 assert again["members_imported"] == 0
assert db_sync.execute(select(ImageRecord)).scalars().all() == images assert db_sync.execute(select(ImageRecord)).scalars().all() == images
def test_reextract_timebox_resumes_from_cursor(db_sync, tmp_path, monkeypatch):
"""A 0-second budget cuts the chunk after the first attachment and reports a
resume cursor; the next run starts strictly after it and finishes the rest."""
from backend.app.tasks import ml as ml_mod
from backend.app.tasks import thumbnail as thumb_mod
monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda *a, **k: None)
monkeypatch.setattr(ml_mod.tag_and_embed, "delay", lambda *a, **k: None)
images_root = tmp_path / "images"
images_root.mkdir()
artist = Artist(name="Bob", slug="bob")
db_sync.add(artist)
db_sync.flush()
source = Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/bob", enabled=True, config_overrides={},
)
db_sync.add(source)
db_sync.flush()
post = Post(
source_id=source.id, artist_id=artist.id, external_post_id="42",
post_url="https://www.patreon.com/posts/42",
)
db_sync.add(post)
db_sync.flush()
store_dir = images_root / "attachments" / "two"
store_dir.mkdir(parents=True)
att_ids = []
for n in range(2):
arc = store_dir / f"{n}_archive"
with zipfile.ZipFile(arc, "w") as zf:
zf.writestr(f"member{n}.jpg", _patterned_jpeg(n))
sha = hashlib.sha256(arc.read_bytes()).hexdigest()
att = PostAttachment(
post_id=post.id, artist_id=artist.id, sha256=sha, path=str(arc),
original_filename=arc.name, ext="", size_bytes=arc.stat().st_size,
)
db_sync.add(att)
db_sync.flush()
att_ids.append(att.id)
db_sync.commit()
# Budget 0 → break right after the first attachment commits.
first = cleanup_service.reextract_archive_attachments(
db_sync, images_root=images_root, time_budget_seconds=0.0,
)
assert first["partial"] is True
assert first["scanned"] == 1
assert first["members_imported"] == 1
assert first["resume_after_id"] == att_ids[0]
# Resume strictly after the cursor — picks up the second, then runs dry.
second = cleanup_service.reextract_archive_attachments(
db_sync, images_root=images_root, after_id=att_ids[0],
)
assert second["partial"] is False
assert second["scanned"] == 1
assert second["members_imported"] == 1
assert len(db_sync.execute(select(ImageRecord)).scalars().all()) == 2