Merge pull request 'Modal focus/keyboard polish, Camie-in-autocomplete, re-extract self-resume' (#82) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m32s
CI / integration (push) Successful in 3m9s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m32s
CI / integration (push) Successful in 3m9s
This commit was merged in pull request #82.
This commit is contained in:
@@ -12,6 +12,7 @@ the one-and-done GS/IR migration tooling.)
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -723,7 +724,13 @@ def _reextract_archive_to_post(
|
||||
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
|
||||
filed opaquely before #713 part 1 (extension-only is_archive missed mangled /
|
||||
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
|
||||
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 ..tasks.ml import tag_and_embed
|
||||
@@ -742,7 +757,7 @@ def reextract_archive_attachments(session: Session, *, images_root: Path) -> dic
|
||||
summary = {
|
||||
"scanned": 0, "archives": 0, "members_imported": 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)
|
||||
importer = Importer(
|
||||
@@ -751,11 +766,15 @@ def reextract_archive_attachments(session: Session, *, images_root: Path) -> dic
|
||||
)
|
||||
|
||||
attachments = session.execute(
|
||||
select(PostAttachment).order_by(PostAttachment.id)
|
||||
select(PostAttachment)
|
||||
.where(PostAttachment.id > after_id)
|
||||
.order_by(PostAttachment.id)
|
||||
).scalars().all()
|
||||
enqueue_ids: list[int] = []
|
||||
start = time.monotonic()
|
||||
for att in attachments:
|
||||
summary["scanned"] += 1
|
||||
summary["resume_after_id"] = att.id
|
||||
stored = Path(att.path)
|
||||
try:
|
||||
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
|
||||
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
|
||||
# critical path — a Redis hiccup must not fail the whole re-extract).
|
||||
for img_id in enqueue_ids:
|
||||
|
||||
@@ -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(
|
||||
name="backend.app.tasks.admin.reextract_archive_attachments_task",
|
||||
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,
|
||||
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):
|
||||
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()
|
||||
with SessionLocal() as session:
|
||||
return cleanup_service.reextract_archive_attachments(
|
||||
summary = cleanup_service.reextract_archive_attachments(
|
||||
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
|
||||
|
||||
@@ -2,22 +2,37 @@
|
||||
<v-card>
|
||||
<v-card-title>Pick a fandom</v-card-title>
|
||||
<v-card-text>
|
||||
<!-- A2/A3 (2026-06-07): autofocus so the operator types immediately, and
|
||||
picking a fandom (keyboard: type → arrow → Enter, or a click) confirms
|
||||
in one step — selecting IS the decision in this dialog. -->
|
||||
<!-- Keyboard flow (operator-specified 2026-06-07):
|
||||
1. Focus lands here (driven by the dialog's @after-enter calling the
|
||||
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
|
||||
ref="fandomRef"
|
||||
v-model="selectedId"
|
||||
v-model:menu="menuOpen"
|
||||
:items="store.fandomCache"
|
||||
:item-title="(f) => f.name"
|
||||
:item-value="(f) => f.id"
|
||||
label="Fandom" clearable density="compact"
|
||||
autofocus
|
||||
@update:model-value="onSelect"
|
||||
@keydown.enter="onSearchEnter"
|
||||
@keydown.tab="onSearchTab"
|
||||
/>
|
||||
<v-divider class="my-3" />
|
||||
<p class="text-caption mb-2">Or create a new fandom:</p>
|
||||
<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>
|
||||
</div>
|
||||
</v-card-text>
|
||||
@@ -34,7 +49,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel'])
|
||||
@@ -42,28 +57,61 @@ const store = useTagStore()
|
||||
|
||||
const selectedId = ref(null)
|
||||
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).
|
||||
onMounted(() => store.loadFandoms())
|
||||
|
||||
async function onCreate() {
|
||||
const f = await store.createFandom(newName.value.trim())
|
||||
// Exposed so the parent dialog can focus the search field on @after-enter —
|
||||
// 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
|
||||
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)
|
||||
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.
|
||||
function onSelect(id) {
|
||||
if (id == null) return
|
||||
onConfirm()
|
||||
|
||||
// Enter on the search field. When the menu is open Vuetify is picking the
|
||||
// highlighted item (it sets selectedId + closes the menu) — don't also accept
|
||||
// on that keystroke. With the menu closed and a value chosen, Enter accepts.
|
||||
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
|
||||
// was a deliberate "unassigned", not a cancel.
|
||||
function onNoFandom() {
|
||||
function onNoFandom () {
|
||||
emit('confirm', null)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,44 +12,83 @@
|
||||
@keydown.esc="$emit('cancel')"
|
||||
/>
|
||||
<v-list
|
||||
v-if="hits.length || allowCreate"
|
||||
v-if="rows.length"
|
||||
ref="listRef"
|
||||
density="compact" class="fc-tag-autocomplete__list"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(h, idx) in hits" :key="h.id"
|
||||
:active="idx === highlight" @click="onPick(h)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small" :color="store.colorFor(h.kind)">
|
||||
{{ iconFor(h.kind) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ h.name }}
|
||||
<span v-if="h.fandom_name" class="text-caption">— {{ h.fandom_name }}</span>
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<span class="text-caption">{{ h.kind }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="allowCreate" :active="highlight === hits.length"
|
||||
@click="onCreate"
|
||||
>
|
||||
<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-for="(row, idx) in rows" :key="row.key">
|
||||
<!-- Existing tag match (server autocomplete). -->
|
||||
<v-list-item
|
||||
v-if="row.type === 'hit'"
|
||||
:active="idx === highlight" @click="onPickRow(row)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small" :color="store.colorFor(row.hit.kind)">
|
||||
{{ iconFor(row.hit.kind) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ row.hit.name }}
|
||||
<span v-if="row.hit.fandom_name" class="text-caption">— {{ row.hit.fandom_name }}</span>
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<span class="text-caption">{{ row.hit.kind }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<!-- ML suggestion for THIS image that matches the typed query. Picking
|
||||
it routes through the same accept path as the Suggestions panel, so
|
||||
it's recorded + drops out of the panel (operator-asked 2026-06-07). -->
|
||||
<v-list-item
|
||||
v-else-if="row.type === 'suggestion'"
|
||||
:active="idx === highlight" @click="onPickRow(row)"
|
||||
class="fc-tag-autocomplete__sugg"
|
||||
>
|
||||
<template #prepend>
|
||||
<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-dialog v-model="fandomDialog" max-width="480">
|
||||
<FandomPicker @confirm="onFandomChosen" @cancel="fandomDialog = false" />
|
||||
<!-- @after-enter is the reliable moment to focus the picker: the dialog's
|
||||
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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -57,10 +96,15 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
import { useSuggestionsStore } from '../../stores/suggestions.js'
|
||||
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()
|
||||
// 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
|
||||
// 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
|
||||
// safe on plain HTTP (not a secure-context API).
|
||||
const inputRef = ref(null)
|
||||
onMounted(() => {
|
||||
function focusInput () {
|
||||
if (window.matchMedia?.('(max-width: 900px)')?.matches) return
|
||||
nextTick(() => inputRef.value?.focus?.())
|
||||
})
|
||||
}
|
||||
onMounted(focusInput)
|
||||
|
||||
// Single text input; no kind dropdown. Client-side mirror of the
|
||||
// backend's parse_kind_prefix lives below — kept in sync with
|
||||
@@ -89,6 +134,7 @@ const hits = ref([])
|
||||
const highlight = ref(0)
|
||||
const listRef = ref(null)
|
||||
const fandomDialog = ref(false)
|
||||
const fandomPickerRef = ref(null)
|
||||
let pendingNewName = null
|
||||
|
||||
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) {
|
||||
const total = hits.value.length + (allowCreate.value ? 1 : 0)
|
||||
const total = rows.value.length
|
||||
if (total === 0) return
|
||||
highlight.value = (highlight.value + delta + total) % total
|
||||
// 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 () {
|
||||
const name = parsedName.value
|
||||
@@ -178,21 +269,29 @@ function onFandomChosen (fandom) {
|
||||
})
|
||||
pendingNewName = null
|
||||
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 () {
|
||||
if (highlight.value < hits.value.length) {
|
||||
onPick(hits.value[highlight.value])
|
||||
} else if (allowCreate.value) {
|
||||
onCreate()
|
||||
}
|
||||
const row = rows.value[highlight.value]
|
||||
if (row) onPickRow(row)
|
||||
}
|
||||
|
||||
// B5 (2026-06-07): when the suggestion list is open, Tab accepts the
|
||||
// highlighted row (standard autocomplete convention) instead of leaving the
|
||||
// field. With the list closed it falls through to normal focus traversal.
|
||||
// B5 (2026-06-07): when the dropdown is open, Tab accepts the highlighted row
|
||||
// (standard autocomplete convention) instead of leaving the field. With the
|
||||
// list closed it falls through to normal focus traversal.
|
||||
function onTab (e) {
|
||||
if (hits.value.length || allowCreate.value) {
|
||||
if (rows.value.length) {
|
||||
e.preventDefault()
|
||||
onEnter()
|
||||
}
|
||||
@@ -210,4 +309,14 @@ function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
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>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<TagAutocomplete
|
||||
@pick-existing="onPickExisting" @pick-new="onPickNew"
|
||||
@accept-suggestion="onAcceptSuggestion"
|
||||
/>
|
||||
|
||||
<v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable>
|
||||
@@ -45,6 +46,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useModalStore } from '../../stores/modal.js'
|
||||
import { useSuggestionsStore } from '../../stores/suggestions.js'
|
||||
import TagChip from './TagChip.vue'
|
||||
import TagAutocomplete from './TagAutocomplete.vue'
|
||||
import SuggestionsPanel from './SuggestionsPanel.vue'
|
||||
@@ -52,6 +54,7 @@ import TagRenameDialog from './TagRenameDialog.vue'
|
||||
import FandomSetDialog from './FandomSetDialog.vue'
|
||||
|
||||
const modal = useModalStore()
|
||||
const suggestions = useSuggestionsStore()
|
||||
const errorMsg = ref(null)
|
||||
|
||||
async function onRemove(tagId) {
|
||||
@@ -69,6 +72,16 @@ async function onPickNew(payload) {
|
||||
try { await modal.createAndAdd(payload) }
|
||||
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 renameTarget = ref(null)
|
||||
|
||||
@@ -6,7 +6,7 @@ import io
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from PIL import Image, ImageDraw
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models import (
|
||||
@@ -28,6 +28,18 @@ def _jpeg(color, size=256):
|
||||
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):
|
||||
from backend.app.tasks import ml as ml_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 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
|
||||
|
||||
Reference in New Issue
Block a user