Unused-tag fandom fix + ML-worker logging/tuning + unified dropdown Enter #86
@@ -21,6 +21,7 @@ from sqlalchemy import func, or_, select, update
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..models import Artist, ImageRecord, LibraryAuditRun, Tag
|
from ..models import Artist, ImageRecord, LibraryAuditRun, Tag
|
||||||
|
from ..models.series_chapter import SeriesChapter
|
||||||
from ..models.series_page import SeriesPage
|
from ..models.series_page import SeriesPage
|
||||||
from ..models.tag import image_tag
|
from ..models.tag import image_tag
|
||||||
|
|
||||||
@@ -141,21 +142,36 @@ def count_tag_associations(session: Session, *, tag_id: int) -> int:
|
|||||||
def find_unused_tags(
|
def find_unused_tags(
|
||||||
session: Session, *, limit: int | None = None,
|
session: Session, *, limit: int | None = None,
|
||||||
) -> list[Tag]:
|
) -> list[Tag]:
|
||||||
"""Tags with no image_tag rows AND no series_page rows.
|
"""Tags genuinely referenced by nothing — safe to sweep.
|
||||||
|
|
||||||
Sorted by name. Used by both dry-run preview and the live prune.
|
Sorted by name. Used by both dry-run preview and the live prune. A tag is
|
||||||
A tag is "unused" iff it has zero rows in image_tag AND zero rows
|
"unused" iff it has zero references across ALL the ways a tag can be in use:
|
||||||
in series_page (so we don't accidentally prune a series tag that
|
- image_tag (applied to an image)
|
||||||
happens to have no images yet).
|
- series_page (a series tag with ordered pages)
|
||||||
|
- series_chapter (a series tag with chapters but no pages yet, e.g.
|
||||||
|
all-placeholder)
|
||||||
|
- tag.fandom_id (a fandom referenced by a character)
|
||||||
|
|
||||||
|
The fandom check is essential: fandom tags are NEVER applied to images — a
|
||||||
|
character carries its fandom via fandom_id — so without it every assigned
|
||||||
|
fandom looked "unused", and the FK is ondelete=SET NULL, so deleting one
|
||||||
|
would silently strip the fandom off all its characters (operator-flagged
|
||||||
|
2026-06-08).
|
||||||
"""
|
"""
|
||||||
used_via_image_tag = select(image_tag.c.tag_id).distinct()
|
used_via_image_tag = select(image_tag.c.tag_id).distinct()
|
||||||
used_via_series = select(SeriesPage.series_tag_id).where(
|
used_via_series = select(SeriesPage.series_tag_id).where(
|
||||||
SeriesPage.series_tag_id.is_not(None)
|
SeriesPage.series_tag_id.is_not(None)
|
||||||
).distinct()
|
).distinct()
|
||||||
|
used_via_chapter = select(SeriesChapter.series_tag_id).distinct()
|
||||||
|
used_via_fandom = select(Tag.fandom_id).where(
|
||||||
|
Tag.fandom_id.is_not(None)
|
||||||
|
).distinct()
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Tag)
|
select(Tag)
|
||||||
.where(Tag.id.not_in(used_via_image_tag))
|
.where(Tag.id.not_in(used_via_image_tag))
|
||||||
.where(Tag.id.not_in(used_via_series))
|
.where(Tag.id.not_in(used_via_series))
|
||||||
|
.where(Tag.id.not_in(used_via_chapter))
|
||||||
|
.where(Tag.id.not_in(used_via_fandom))
|
||||||
.order_by(Tag.name)
|
.order_by(Tag.name)
|
||||||
)
|
)
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
|
|||||||
+126
-53
@@ -6,8 +6,10 @@ apply_allowlist_tags sweeps which are 'maintenance' lane. Sync sessions
|
|||||||
(Celery workers are sync processes), same pattern as FC-2a tasks.
|
(Celery workers are sync processes), same pattern as FC-2a tasks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from celery.exceptions import SoftTimeLimitExceeded
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import DBAPIError, OperationalError
|
from sqlalchemy.exc import DBAPIError, OperationalError
|
||||||
|
|
||||||
@@ -15,6 +17,8 @@ from ..celery_app import celery
|
|||||||
from ..models import ImageRecord, MLSettings
|
from ..models import ImageRecord, MLSettings
|
||||||
from ._sync_engine import sync_session_factory as _sync_session_factory
|
from ._sync_engine import sync_session_factory as _sync_session_factory
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
IMAGES_ROOT = Path("/images")
|
IMAGES_ROOT = Path("/images")
|
||||||
VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv"}
|
VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv"}
|
||||||
|
|
||||||
@@ -31,8 +35,8 @@ def _is_video(path: Path) -> bool:
|
|||||||
retry_backoff_max=60,
|
retry_backoff_max=60,
|
||||||
retry_jitter=True,
|
retry_jitter=True,
|
||||||
max_retries=3,
|
max_retries=3,
|
||||||
# Sized for the video branch: sample 10 frames, run tagger +
|
# Sized for the video branch: sample 6 frames, run tagger +
|
||||||
# embedder on each (≈20 GPU ops vs 2 for an image). A loaded
|
# embedder on each (≈12 GPU ops vs 2 for an image). A loaded
|
||||||
# ml-worker can take 5-10 min on a long video; bumped from
|
# ml-worker can take 5-10 min on a long video; bumped from
|
||||||
# 5min/7min on 2026-05-28 after operator-flagged image 6288 (a
|
# 5min/7min on 2026-05-28 after operator-flagged image 6288 (a
|
||||||
# .mp4) hit the recovery sweep at 5 min while still legitimately
|
# .mp4) hit the recovery sweep at 5 min while still legitimately
|
||||||
@@ -46,71 +50,140 @@ def tag_and_embed(self, image_id: int) -> dict:
|
|||||||
then enqueue per-image allowlist application.
|
then enqueue per-image allowlist application.
|
||||||
|
|
||||||
Video: sample frames between 10% and 90% of duration (VIDEO_ML_FRAMES,
|
Video: sample frames between 10% and 90% of duration (VIDEO_ML_FRAMES,
|
||||||
default 10). Max-pool tagger confidences across frames, mean-pool the
|
default 6). Max-pool tagger confidences across frames, mean-pool the
|
||||||
SigLIP embeddings. On no-frames returns status='no_frames' (not an error).
|
SigLIP embeddings. On no-frames returns status='no_frames' (not an error).
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
from ..services.ml.embedder import get_embedder
|
from ..services.ml.embedder import get_embedder
|
||||||
from ..services.ml.tagger import get_tagger
|
from ..services.ml.tagger import get_tagger
|
||||||
|
|
||||||
SessionLocal = _sync_session_factory()
|
# Phase + file context, so a timeout/crash names WHICH file and WHERE it
|
||||||
with SessionLocal() as session:
|
# died instead of a bare SoftTimeLimitExceeded() (operator-flagged 2026-06-08:
|
||||||
record = session.get(ImageRecord, image_id)
|
# the activity told them nothing about the file or why). `ctx` is enriched
|
||||||
if record is None:
|
# once the record is loaded; both feed the worker log AND the re-raised
|
||||||
return {"status": "missing", "image_id": image_id}
|
# exception message (which becomes the activity's error_message).
|
||||||
settings = session.execute(
|
started = time.monotonic()
|
||||||
select(MLSettings).where(MLSettings.id == 1)
|
phase = "open_session"
|
||||||
).scalar_one()
|
ctx = f"image_id={image_id}"
|
||||||
|
|
||||||
src = Path(record.path)
|
def _elapsed() -> float:
|
||||||
if not src.is_file():
|
return time.monotonic() - started
|
||||||
return {"status": "file_missing", "image_id": image_id}
|
|
||||||
|
|
||||||
tagger = get_tagger()
|
try:
|
||||||
embedder = get_embedder()
|
SessionLocal = _sync_session_factory()
|
||||||
|
with SessionLocal() as session:
|
||||||
|
record = session.get(ImageRecord, image_id)
|
||||||
|
if record is None:
|
||||||
|
return {"status": "missing", "image_id": image_id}
|
||||||
|
settings = session.execute(
|
||||||
|
select(MLSettings).where(MLSettings.id == 1)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
if _is_video(src):
|
src = Path(record.path)
|
||||||
# Layer-3 isolation: ffprobe (a separate process) validates
|
is_vid = _is_video(src)
|
||||||
# the container before we burn ~20 GPU ops sampling frames
|
ctx = (
|
||||||
# from it. A corrupt video that would crash the frame
|
f"image_id={image_id} path={record.path} mime={record.mime} "
|
||||||
# decoder is rejected cleanly here instead of taking down
|
f"bytes={record.size_bytes} video={is_vid}"
|
||||||
# the ml-worker. Operator-flagged 2026-05-28.
|
|
||||||
from ..utils import safe_probe
|
|
||||||
vprobe = safe_probe.probe_video(src)
|
|
||||||
if not vprobe.ok:
|
|
||||||
return {
|
|
||||||
"status": "bad_video", "image_id": image_id,
|
|
||||||
"reason": vprobe.reason,
|
|
||||||
}
|
|
||||||
frames = _sample_video_frames(
|
|
||||||
src, int(os.environ.get("VIDEO_ML_FRAMES", "10"))
|
|
||||||
)
|
)
|
||||||
if not frames:
|
log.info("tag_and_embed start: %s", ctx)
|
||||||
return {"status": "no_frames", "image_id": image_id}
|
if not src.is_file():
|
||||||
preds = _maxpool_predictions([tagger.infer(f) for f in frames])
|
log.warning("tag_and_embed file missing on disk: %s", ctx)
|
||||||
import numpy as np
|
return {"status": "file_missing", "image_id": image_id}
|
||||||
|
|
||||||
embedding = np.mean(
|
phase = "load_models"
|
||||||
[embedder.infer(f) for f in frames], axis=0
|
tagger = get_tagger()
|
||||||
).astype("float32")
|
embedder = get_embedder()
|
||||||
for f in frames:
|
|
||||||
f.unlink(missing_ok=True)
|
|
||||||
else:
|
|
||||||
raw = tagger.infer(src)
|
|
||||||
preds = {
|
|
||||||
name: {"category": p.category, "confidence": p.confidence}
|
|
||||||
for name, p in raw.items()
|
|
||||||
}
|
|
||||||
embedding = embedder.infer(src)
|
|
||||||
|
|
||||||
record.tagger_predictions = preds
|
if is_vid:
|
||||||
record.tagger_model_version = settings.tagger_model_version
|
# Layer-3 isolation: ffprobe (a separate process) validates
|
||||||
record.siglip_embedding = embedding.tolist()
|
# the container before we burn ~20 GPU ops sampling frames
|
||||||
record.siglip_model_version = settings.embedder_model_version
|
# from it. A corrupt video that would crash the frame
|
||||||
session.add(record)
|
# decoder is rejected cleanly here instead of taking down
|
||||||
session.commit()
|
# the ml-worker. Operator-flagged 2026-05-28.
|
||||||
|
phase = "video_probe"
|
||||||
|
from ..utils import safe_probe
|
||||||
|
vprobe = safe_probe.probe_video(src)
|
||||||
|
if not vprobe.ok:
|
||||||
|
log.warning(
|
||||||
|
"tag_and_embed bad video (%s): %s", vprobe.reason, ctx
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "bad_video", "image_id": image_id,
|
||||||
|
"reason": vprobe.reason,
|
||||||
|
}
|
||||||
|
phase = "video_sample_frames"
|
||||||
|
t0 = time.monotonic()
|
||||||
|
frames = _sample_video_frames(
|
||||||
|
src, int(os.environ.get("VIDEO_ML_FRAMES", "6"))
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"tag_and_embed sampled %d frame(s) in %.1fs: %s",
|
||||||
|
len(frames), time.monotonic() - t0, ctx,
|
||||||
|
)
|
||||||
|
if not frames:
|
||||||
|
return {"status": "no_frames", "image_id": image_id}
|
||||||
|
phase = "video_infer"
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
preds = _maxpool_predictions([tagger.infer(f) for f in frames])
|
||||||
|
embedding = np.mean(
|
||||||
|
[embedder.infer(f) for f in frames], axis=0
|
||||||
|
).astype("float32")
|
||||||
|
for f in frames:
|
||||||
|
f.unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
phase = "tag"
|
||||||
|
t0 = time.monotonic()
|
||||||
|
raw = tagger.infer(src)
|
||||||
|
log.info(
|
||||||
|
"tag_and_embed tagged in %.1fs (%d tags): %s",
|
||||||
|
time.monotonic() - t0, len(raw), ctx,
|
||||||
|
)
|
||||||
|
preds = {
|
||||||
|
name: {"category": p.category, "confidence": p.confidence}
|
||||||
|
for name, p in raw.items()
|
||||||
|
}
|
||||||
|
phase = "embed"
|
||||||
|
t0 = time.monotonic()
|
||||||
|
embedding = embedder.infer(src)
|
||||||
|
log.info(
|
||||||
|
"tag_and_embed embedded in %.1fs: %s",
|
||||||
|
time.monotonic() - t0, ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
phase = "persist"
|
||||||
|
record.tagger_predictions = preds
|
||||||
|
record.tagger_model_version = settings.tagger_model_version
|
||||||
|
record.siglip_embedding = embedding.tolist()
|
||||||
|
record.siglip_model_version = settings.embedder_model_version
|
||||||
|
session.add(record)
|
||||||
|
session.commit()
|
||||||
|
except SoftTimeLimitExceeded:
|
||||||
|
log.error(
|
||||||
|
"tag_and_embed TIMED OUT after %.0fs in phase=%s: %s",
|
||||||
|
_elapsed(), phase, ctx,
|
||||||
|
)
|
||||||
|
# Re-raise as SoftTimeLimitExceeded (preserves the 'timeout' status in
|
||||||
|
# the task_run signal) but WITH context, so the activity error_message
|
||||||
|
# names the file + phase instead of being empty.
|
||||||
|
raise SoftTimeLimitExceeded(
|
||||||
|
f"timed out in phase={phase} after {_elapsed():.0f}s ({ctx})"
|
||||||
|
) from None
|
||||||
|
except Exception:
|
||||||
|
# OSError/DBAPIError/OperationalError are autoretried — re-raise the
|
||||||
|
# ORIGINAL so the type is preserved; just make sure it's logged with
|
||||||
|
# context first.
|
||||||
|
log.exception(
|
||||||
|
"tag_and_embed FAILED in phase=%s after %.0fs: %s",
|
||||||
|
phase, _elapsed(), ctx,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"tag_and_embed ok in %.1fs (%d tags): %s", _elapsed(), len(preds), ctx
|
||||||
|
)
|
||||||
apply_allowlist_tags.delay(image_id=image_id)
|
apply_allowlist_tags.delay(image_id=image_id)
|
||||||
return {"status": "ok", "image_id": image_id, "tags": len(preds)}
|
return {"status": "ok", "image_id": image_id, "tags": len(preds)}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
v-model="selectedId"
|
v-model="selectedId"
|
||||||
|
v-model:menu="menuOpen"
|
||||||
:items="results"
|
:items="results"
|
||||||
:item-title="(t) => t.fandom_name ? `${t.name} — ${t.fandom_name}` : t.name"
|
:item-title="(t) => t.fandom_name ? `${t.name} — ${t.fandom_name}` : t.name"
|
||||||
:item-value="(t) => t.id"
|
:item-value="(t) => t.id"
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
label="Canonical tag"
|
label="Canonical tag"
|
||||||
no-filter clearable density="compact"
|
no-filter clearable density="compact"
|
||||||
@update:search="onSearch"
|
@update:search="onSearch"
|
||||||
|
@keydown.enter.capture="onEnter"
|
||||||
/>
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
@@ -31,9 +33,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useApi } from '../../composables/useApi.js'
|
import { useApi } from '../../composables/useApi.js'
|
||||||
|
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
|
||||||
|
|
||||||
const props = defineProps({ category: { type: String, required: true } })
|
const props = defineProps({ category: { type: String, required: true } })
|
||||||
defineEmits(['confirm', 'cancel'])
|
const emit = defineEmits(['confirm', 'cancel'])
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const results = ref([])
|
const results = ref([])
|
||||||
@@ -41,6 +44,11 @@ const loading = ref(false)
|
|||||||
const selectedId = ref(null)
|
const selectedId = ref(null)
|
||||||
let debounce = null
|
let debounce = null
|
||||||
|
|
||||||
|
// Enter on the closed dropdown confirms the selection instead of re-opening it.
|
||||||
|
const { menuOpen, onEnter } = useAcceptOnEnter(() => {
|
||||||
|
if (selectedId.value != null) emit('confirm', selectedId.value)
|
||||||
|
})
|
||||||
|
|
||||||
function onSearch(q) {
|
function onSearch(q) {
|
||||||
if (debounce) clearTimeout(debounce)
|
if (debounce) clearTimeout(debounce)
|
||||||
debounce = setTimeout(async () => {
|
debounce = setTimeout(async () => {
|
||||||
|
|||||||
@@ -51,16 +51,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, onMounted, ref } from 'vue'
|
import { nextTick, onMounted, ref } from 'vue'
|
||||||
import { useTagStore } from '../../stores/tags.js'
|
import { useTagStore } from '../../stores/tags.js'
|
||||||
|
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
|
||||||
|
|
||||||
const emit = defineEmits(['confirm', 'cancel'])
|
const emit = defineEmits(['confirm', 'cancel'])
|
||||||
const store = useTagStore()
|
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 fandomRef = ref(null)
|
||||||
const newNameRef = ref(null)
|
const newNameRef = ref(null)
|
||||||
|
|
||||||
|
// Enter on the closed dropdown accepts the chosen fandom instead of re-opening it.
|
||||||
|
const { menuOpen, onEnter: onSearchEnter } = useAcceptOnEnter(() => onConfirm())
|
||||||
|
|
||||||
// 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())
|
||||||
|
|
||||||
@@ -92,22 +95,6 @@ function onConfirm () {
|
|||||||
if (f) emit('confirm', f)
|
if (f) emit('confirm', f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter on the search field — bound in the CAPTURE phase so this runs BEFORE
|
|
||||||
// Vuetify's own input keydown handler (which opens the menu on Enter). When the
|
|
||||||
// menu is open, let Vuetify pick the highlighted item (it sets selectedId +
|
|
||||||
// closes the menu). When it's closed and a fandom is already chosen, accept it
|
|
||||||
// and stop the event so Vuetify never (re)opens the menu — that re-open was the
|
|
||||||
// bug: Enter popped the dropdown instead of submitting (operator-flagged
|
|
||||||
// 2026-06-07).
|
|
||||||
function onSearchEnter (e) {
|
|
||||||
if (menuOpen.value) return
|
|
||||||
if (selectedId.value != null) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
onConfirm()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab from the search field goes to the "new fandom" text field (operator-
|
// Tab from the search field goes to the "new fandom" text field (operator-
|
||||||
// specified). Shift+Tab keeps normal reverse traversal.
|
// specified). Shift+Tab keeps normal reverse traversal.
|
||||||
function onSearchTab (e) {
|
function onSearchTab (e) {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, onMounted, ref } from 'vue'
|
import { nextTick, onMounted, ref } from 'vue'
|
||||||
import { useTagStore } from '../../stores/tags.js'
|
import { useTagStore } from '../../stores/tags.js'
|
||||||
|
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
|
||||||
|
|
||||||
const props = defineProps({ tag: { type: Object, required: true } })
|
const props = defineProps({ tag: { type: Object, required: true } })
|
||||||
const emit = defineEmits(['updated', 'cancel'])
|
const emit = defineEmits(['updated', 'cancel'])
|
||||||
@@ -93,10 +94,15 @@ const newName = ref('')
|
|||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const collision = ref(null)
|
const collision = ref(null)
|
||||||
const menuOpen = ref(false)
|
|
||||||
const fandomRef = ref(null)
|
const fandomRef = ref(null)
|
||||||
const newNameRef = ref(null)
|
const newNameRef = ref(null)
|
||||||
|
|
||||||
|
// Enter on the closed dropdown Saves the changed fandom instead of re-opening it.
|
||||||
|
const { menuOpen, onEnter: onSearchEnter } = useAcceptOnEnter(() => {
|
||||||
|
if (busy.value) return
|
||||||
|
if (selectedId.value !== (props.tag.fandom_id ?? null)) onSave()
|
||||||
|
})
|
||||||
|
|
||||||
// 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())
|
||||||
|
|
||||||
@@ -131,20 +137,6 @@ async function onCreate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter on the search field — bound in the CAPTURE phase so it runs BEFORE
|
|
||||||
// Vuetify's own handler (which opens the menu on Enter). Menu open → let Vuetify
|
|
||||||
// pick the highlighted item. Menu closed with a changed selection → Save and stop
|
|
||||||
// the event so Vuetify never (re)opens the dropdown (the bug this fixes).
|
|
||||||
function onSearchEnter(e) {
|
|
||||||
if (menuOpen.value) return
|
|
||||||
if (busy.value) return
|
|
||||||
if (selectedId.value !== (props.tag.fandom_id ?? null)) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
onSave()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab from the search field goes to the "new fandom" text field (operator-
|
// Tab from the search field goes to the "new fandom" text field (operator-
|
||||||
// specified). Shift+Tab keeps normal reverse traversal.
|
// specified). Shift+Tab keeps normal reverse traversal.
|
||||||
function onSearchTab(e) {
|
function onSearchTab(e) {
|
||||||
|
|||||||
@@ -32,11 +32,13 @@
|
|||||||
</p>
|
</p>
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
v-model="picked"
|
v-model="picked"
|
||||||
|
v-model:menu="pickerMenuOpen"
|
||||||
:items="hits"
|
:items="hits"
|
||||||
:item-title="(s) => s.name"
|
:item-title="(s) => s.name"
|
||||||
:item-value="(s) => s.id"
|
:item-value="(s) => s.id"
|
||||||
:loading="searching"
|
:loading="searching"
|
||||||
label="Series" density="compact" autofocus clearable
|
label="Series" density="compact" autofocus clearable
|
||||||
|
@keydown.enter.capture="onEnter"
|
||||||
/>
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
@@ -56,6 +58,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useApi } from '../../composables/useApi.js'
|
import { useApi } from '../../composables/useApi.js'
|
||||||
|
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
|
||||||
import { toast } from '../../utils/toast.js'
|
import { toast } from '../../utils/toast.js'
|
||||||
|
|
||||||
const props = defineProps({ post: { type: Object, required: true } })
|
const props = defineProps({ post: { type: Object, required: true } })
|
||||||
@@ -70,6 +73,12 @@ const picked = ref(null)
|
|||||||
const hits = ref([])
|
const hits = ref([])
|
||||||
const searching = ref(false)
|
const searching = ref(false)
|
||||||
|
|
||||||
|
// Enter on the closed dropdown adds the chosen series instead of re-opening it.
|
||||||
|
// (aliased — `menuOpen` above is the outer "Series ▾" v-menu's state.)
|
||||||
|
const { menuOpen: pickerMenuOpen, onEnter } = useAcceptOnEnter(() => {
|
||||||
|
if (picked.value != null && !busy.value) onAddExisting()
|
||||||
|
})
|
||||||
|
|
||||||
async function onPromote() {
|
async function onPromote() {
|
||||||
menuOpen.value = false
|
menuOpen.value = false
|
||||||
busy.value = true
|
busy.value = true
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unifies keyboard behavior for confirm-style `v-autocomplete` / `v-select`
|
||||||
|
* dropdowns — a single picker whose Enter should trigger the surrounding
|
||||||
|
* modal's primary action (Merge, Add, Save, Use…).
|
||||||
|
*
|
||||||
|
* The recurring bug (operator-flagged across the merge / fandom / series / alias
|
||||||
|
* dialogs): pressing Enter with the menu CLOSED re-opens the dropdown instead of
|
||||||
|
* accepting the chosen value, because Vuetify's own Enter handler opens the menu.
|
||||||
|
* We bind in the CAPTURE phase so this runs BEFORE Vuetify's handler:
|
||||||
|
* - menu OPEN → return; let Vuetify pick the highlighted item (it sets the
|
||||||
|
* value and closes the menu).
|
||||||
|
* - menu CLOSED → preventDefault + stopPropagation so Vuetify can't re-open the
|
||||||
|
* menu, then call `accept()` — the modal's confirm. `accept`
|
||||||
|
* should itself no-op when nothing valid is selected.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const { menuOpen, onEnter } = useAcceptOnEnter(onConfirm)
|
||||||
|
* <v-autocomplete v-model:menu="menuOpen" @keydown.enter.capture="onEnter" … />
|
||||||
|
*
|
||||||
|
* `menuOpen` is exposed so a component that programmatically focuses the field
|
||||||
|
* can force the menu shut (so the very next Enter accepts rather than being
|
||||||
|
* swallowed as a menu interaction).
|
||||||
|
*/
|
||||||
|
export function useAcceptOnEnter(accept) {
|
||||||
|
const menuOpen = ref(false)
|
||||||
|
function onEnter(e) {
|
||||||
|
if (menuOpen.value) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
accept()
|
||||||
|
}
|
||||||
|
return { menuOpen, onEnter }
|
||||||
|
}
|
||||||
@@ -55,11 +55,13 @@
|
|||||||
</p>
|
</p>
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
v-model="mergeTargetId"
|
v-model="mergeTargetId"
|
||||||
|
v-model:menu="mergeMenuOpen"
|
||||||
:items="mergeHits" item-title="name" item-value="id"
|
:items="mergeHits" item-title="name" item-value="id"
|
||||||
:loading="mergeLoading"
|
:loading="mergeLoading"
|
||||||
density="compact" variant="outlined" hide-details
|
density="compact" variant="outlined" hide-details
|
||||||
placeholder="Search target tag…" no-filter
|
placeholder="Search target tag…" no-filter
|
||||||
@update:search="onMergeSearch"
|
@update:search="onMergeSearch"
|
||||||
|
@keydown.enter.capture="onMergeEnter"
|
||||||
/>
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
@@ -107,6 +109,7 @@ import { useTagDirectoryStore } from '../stores/tagDirectory.js'
|
|||||||
import { useAdminStore } from '../stores/admin.js'
|
import { useAdminStore } from '../stores/admin.js'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
||||||
|
import { useAcceptOnEnter } from '../composables/useAcceptOnEnter.js'
|
||||||
import TagCard from '../components/discovery/TagCard.vue'
|
import TagCard from '../components/discovery/TagCard.vue'
|
||||||
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
|
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
|
||||||
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
|
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
|
||||||
@@ -181,6 +184,10 @@ const api = useApi()
|
|||||||
|
|
||||||
// Tag merge via dots-menu (separate from inline-rename collision flow above)
|
// Tag merge via dots-menu (separate from inline-rename collision flow above)
|
||||||
const mergePickerOpen = ref(false)
|
const mergePickerOpen = ref(false)
|
||||||
|
// Enter on the closed dropdown accepts the target instead of re-opening it.
|
||||||
|
const { menuOpen: mergeMenuOpen, onEnter: onMergeEnter } = useAcceptOnEnter(
|
||||||
|
() => onMergeConfirm()
|
||||||
|
)
|
||||||
const mergeSource = ref(null)
|
const mergeSource = ref(null)
|
||||||
const mergeTargetId = ref(null)
|
const mergeTargetId = ref(null)
|
||||||
const mergeHits = ref([])
|
const mergeHits = ref([])
|
||||||
|
|||||||
@@ -155,6 +155,31 @@ def test_find_unused_tags_returns_only_unreferenced(db_sync, tmp_path):
|
|||||||
assert names.index("aaa-unused") < names.index("zzz-unused")
|
assert names.index("aaa-unused") < names.index("zzz-unused")
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_unused_tags_excludes_fandom_referenced_by_character(db_sync):
|
||||||
|
# A fandom is never on an image — a character carries it via fandom_id — so
|
||||||
|
# an assigned fandom must NOT be flagged unused (deleting it SET-NULLs the
|
||||||
|
# fandom off its characters). Operator-flagged 2026-06-08.
|
||||||
|
fandom = _make_tag(db_sync, name="Creux", kind=TagKind.fandom)
|
||||||
|
char = _make_tag(db_sync, name="OcChar", kind=TagKind.character)
|
||||||
|
char.fandom_id = fandom.id
|
||||||
|
_make_tag(db_sync, name="NobodysFandom", kind=TagKind.fandom)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
names = [t.name for t in cleanup_service.find_unused_tags(db_sync)]
|
||||||
|
assert "Creux" not in names # referenced by a character → kept
|
||||||
|
assert "NobodysFandom" in names # genuinely orphaned → still swept
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_unused_tags_excludes_series_with_only_chapters(db_sync):
|
||||||
|
# An all-placeholder series has chapters but no pages yet — not unused.
|
||||||
|
series = _make_tag(db_sync, name="EmptySeries", kind=TagKind.series)
|
||||||
|
db_sync.add(SeriesChapter(series_tag_id=series.id, chapter_number=1))
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
names = [t.name for t in cleanup_service.find_unused_tags(db_sync)]
|
||||||
|
assert "EmptySeries" not in names
|
||||||
|
|
||||||
|
|
||||||
# --- unlink_image_files ---------------------------------------------
|
# --- unlink_image_files ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user