feat(settings): tidy Cleanup tab into sectioned compact tiles (pass 1)
The Cleanup + Maintenance sections had ~17 full-width stacked cards with long descriptions — a hunt to scan. Operator wants compact, sectioned, scannable tiles (2026-06-18: keep both tabs, group inside, compact tiles in a grid). New common/MaintenanceTile.vue: a compact expandable tile (icon + short title + one-line blurb collapsed; click the header to expand the full controls/preview/ result inline; keyboard-accessible button + focus ring; tints the icon, keeps a running task expanded). Cleanup tab (this pass) restructured into 3 sections — Import-filter audits (Min dimensions, Transparency, Single-color) / Duplicates & posts (Bare posts, Duplicate posts, Deduplicate videos, Gated-post previews) / Tags (Unused, Legacy, Reset content tagging, Standardize casing) — each a responsive grid of tiles. PostMaintenanceCard split into 2 tiles, TagMaintenanceCard into 4. Moved VideoDedupCard + GatedPurgeCard from the Maintenance tab here (both are destructive content cleanup). All card logic unchanged — only the chrome. Maintenance tab tiling is pass 2 (TODO noted in MaintenancePanel). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,9 +4,13 @@
|
||||
matches by content hash, so real content downloaded when access existed is
|
||||
provably spared. Preview first, then apply (destructive: deletes the
|
||||
matched blurred-preview files). -->
|
||||
<v-card>
|
||||
<v-card-title>Clean up gated-post previews</v-card-title>
|
||||
<v-card-text>
|
||||
<MaintenanceTile
|
||||
icon="mdi-image-off"
|
||||
title="Gated-post previews"
|
||||
blurb="Delete blurred locked-preview images from tier-gated posts."
|
||||
destructive
|
||||
:open="applying || previewing"
|
||||
>
|
||||
<p class="text-body-2 mb-3">
|
||||
Removes the blurred locked-preview images that were grabbed from
|
||||
tier-gated Patreon posts before they were filtered out. It re-walks every
|
||||
@@ -74,7 +78,6 @@
|
||||
</v-alert>
|
||||
|
||||
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance" />
|
||||
</v-card-text>
|
||||
|
||||
<v-dialog v-model="confirmOpen" max-width="460">
|
||||
<v-card>
|
||||
@@ -92,13 +95,14 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-card>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import QueueStatusBar from './QueueStatusBar.vue'
|
||||
|
||||
const confirmOpen = ref(false)
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
<DbMaintenanceCard class="mt-6" />
|
||||
<ArchiveReextractCard class="mt-6" />
|
||||
<MissingFileRepairCard class="mt-6" />
|
||||
<VideoDedupCard class="mt-6" />
|
||||
<GatedPurgeCard class="mt-6" />
|
||||
<BackupCard class="mt-6" />
|
||||
<!-- VideoDedupCard + GatedPurgeCard moved to the Cleanup tab (2026-06-18):
|
||||
both are destructive content cleanup, so "remove unwanted stuff" now
|
||||
lives in one place. -->
|
||||
<!-- TODO(2026-06-18): tile-ify this tab's cards into sections too (pass 2). -->
|
||||
<!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) — it
|
||||
operates on the existing library which fits the Cleanup-tab
|
||||
theme, and clusters with the other audit cards. LegacyMigrationCard
|
||||
@@ -39,8 +41,6 @@ import AliasTable from './AliasTable.vue'
|
||||
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
||||
import ArchiveReextractCard from './ArchiveReextractCard.vue'
|
||||
import MissingFileRepairCard from './MissingFileRepairCard.vue'
|
||||
import VideoDedupCard from './VideoDedupCard.vue'
|
||||
import GatedPurgeCard from './GatedPurgeCard.vue'
|
||||
import BackupCard from './BackupCard.vue'
|
||||
import { useSystemActivityStore } from '../../stores/systemActivity.js'
|
||||
|
||||
|
||||
@@ -1,100 +1,98 @@
|
||||
<template>
|
||||
<v-card class="fc-post-maint">
|
||||
<CardHeading icon="mdi-post-outline" title="Post maintenance" />
|
||||
<v-card-text>
|
||||
<p class="fc-muted text-body-2 mb-4">
|
||||
Remove <strong>bare posts</strong> — post rows with no images (neither
|
||||
their own nor a cross-posted duplicate) and no attachments. These are the
|
||||
empty “Post 12345 / (no description)” shells a backfill can leave when a
|
||||
post's only content was a duplicate that links to an earlier post. Posts
|
||||
that gained a duplicate-image link are spared.
|
||||
<MaintenanceTile
|
||||
icon="mdi-post-outline"
|
||||
title="Bare posts"
|
||||
blurb="Delete empty post shells with no images or attachments."
|
||||
destructive
|
||||
>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Post rows with no images (neither their own nor a cross-posted duplicate)
|
||||
and no attachments — the empty “Post 12345 / (no description)” shells a
|
||||
backfill can leave when a post's only content was a duplicate that links to
|
||||
an earlier post. Posts that gained a duplicate-image link are spared.
|
||||
</p>
|
||||
|
||||
<v-alert
|
||||
v-if="store.lastError"
|
||||
type="warning" variant="tonal" density="compact" class="mb-3"
|
||||
>{{ store.lastError }}</v-alert>
|
||||
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingPreview"
|
||||
class="mb-3"
|
||||
@click="onPreview"
|
||||
>Preview bare posts</v-btn>
|
||||
|
||||
<div v-if="preview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ preview.count }}</strong> bare post(s).
|
||||
<span v-if="preview.count > 50" class="fc-muted">Showing first 50.</span>
|
||||
<span v-else-if="!preview.count" class="fc-muted">Nothing to clean up.</span>
|
||||
</p>
|
||||
|
||||
<v-alert
|
||||
v-if="store.lastError"
|
||||
type="warning" variant="tonal" density="compact" class="mb-3"
|
||||
>{{ store.lastError }}</v-alert>
|
||||
|
||||
<SampleNameGrid
|
||||
v-if="preview.sample_names?.length"
|
||||
:names="preview.sample_names" class="mb-3"
|
||||
/>
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingPreview"
|
||||
class="mb-3"
|
||||
@click="onPreview"
|
||||
>Preview bare posts</v-btn>
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-delete-sweep"
|
||||
:disabled="!preview.count"
|
||||
:loading="committing"
|
||||
@click="onCommit"
|
||||
>Delete {{ preview.count }} bare post(s)</v-btn>
|
||||
<span v-if="deleted != null" class="ml-3 text-caption text-success">
|
||||
Deleted {{ deleted }} ✓
|
||||
</span>
|
||||
</div>
|
||||
</MaintenanceTile>
|
||||
|
||||
<div v-if="preview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ preview.count }}</strong> bare post(s).
|
||||
<span v-if="preview.count > 50" class="fc-muted">
|
||||
Showing first 50.
|
||||
</span>
|
||||
<span v-else-if="!preview.count" class="fc-muted">
|
||||
Nothing to clean up.
|
||||
</span>
|
||||
</p>
|
||||
<SampleNameGrid
|
||||
v-if="preview.sample_names?.length"
|
||||
:names="preview.sample_names" class="mb-3"
|
||||
/>
|
||||
<v-btn
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-delete-sweep"
|
||||
:disabled="!preview.count"
|
||||
:loading="committing"
|
||||
@click="onCommit"
|
||||
>Delete {{ preview.count }} bare post(s)</v-btn>
|
||||
<span
|
||||
v-if="deleted != null"
|
||||
class="ml-3 text-caption text-success"
|
||||
>Deleted {{ deleted }} ✓</span>
|
||||
</div>
|
||||
<MaintenanceTile
|
||||
icon="mdi-merge"
|
||||
title="Duplicate posts"
|
||||
blurb="Merge gallery-dl + native duplicate post records into one."
|
||||
destructive
|
||||
>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
When an artist was first downloaded by gallery-dl and later re-walked by the
|
||||
native ingester, the same post can exist twice (once keyed by the file's
|
||||
attachment id, once by the real post id). This merges each pair onto one
|
||||
canonical post (keeping the native post-id key), moving images, attachments
|
||||
and links onto the survivor. Images themselves are never touched.
|
||||
</p>
|
||||
|
||||
<v-divider class="my-5" />
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingDupPreview"
|
||||
class="mb-3"
|
||||
@click="onPreviewDupes"
|
||||
>Preview duplicate posts</v-btn>
|
||||
|
||||
<p class="fc-muted text-body-2 mb-4">
|
||||
Unify <strong>duplicate posts</strong> — when an artist was first
|
||||
downloaded by gallery-dl and later re-walked by the native ingester, the
|
||||
same post can exist twice (once keyed by the file's attachment id, once by
|
||||
the real post id). This merges each pair onto one canonical post (keeping
|
||||
the native post-id key), moving images, attachments and links onto the
|
||||
survivor. Images themselves are never touched.
|
||||
<div v-if="dupPreview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ dupPreview.groups }}</strong> duplicate group(s),
|
||||
<strong>{{ dupPreview.posts_to_merge }}</strong> redundant row(s) to merge.
|
||||
<span v-if="dupPreview.groups > 50" class="fc-muted">Showing first 50.</span>
|
||||
<span v-else-if="!dupPreview.groups" class="fc-muted">No duplicates found.</span>
|
||||
</p>
|
||||
|
||||
<SampleNameGrid
|
||||
v-if="dupSampleNames.length"
|
||||
:names="dupSampleNames" class="mb-3"
|
||||
/>
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingDupPreview"
|
||||
class="mb-3"
|
||||
@click="onPreviewDupes"
|
||||
>Preview duplicate posts</v-btn>
|
||||
|
||||
<div v-if="dupPreview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ dupPreview.groups }}</strong> duplicate group(s),
|
||||
<strong>{{ dupPreview.posts_to_merge }}</strong> redundant row(s) to
|
||||
merge.
|
||||
<span v-if="dupPreview.groups > 50" class="fc-muted">Showing first 50.</span>
|
||||
<span v-else-if="!dupPreview.groups" class="fc-muted">No duplicates found.</span>
|
||||
</p>
|
||||
<SampleNameGrid
|
||||
v-if="dupSampleNames.length"
|
||||
:names="dupSampleNames" class="mb-3"
|
||||
/>
|
||||
<v-btn
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-merge"
|
||||
:disabled="!dupPreview.groups"
|
||||
:loading="merging"
|
||||
@click="onMergeDupes"
|
||||
>Merge {{ dupPreview.posts_to_merge }} duplicate(s)</v-btn>
|
||||
<span
|
||||
v-if="merged != null"
|
||||
class="ml-3 text-caption text-success"
|
||||
>Merged {{ merged }} ✓</span>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-merge"
|
||||
:disabled="!dupPreview.groups"
|
||||
:loading="merging"
|
||||
@click="onMergeDupes"
|
||||
>Merge {{ dupPreview.posts_to_merge }} duplicate(s)</v-btn>
|
||||
<span v-if="merged != null" class="ml-3 text-caption text-success">
|
||||
Merged {{ merged }} ✓
|
||||
</span>
|
||||
</div>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -102,7 +100,7 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { useAdminStore } from '../../stores/admin.js'
|
||||
import SampleNameGrid from '../common/SampleNameGrid.vue'
|
||||
import CardHeading from '../common/CardHeading.vue'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
|
||||
const store = useAdminStore()
|
||||
const preview = ref(null)
|
||||
@@ -165,7 +163,3 @@ async function onMergeDupes() {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-post-maint { border-radius: 8px; }
|
||||
</style>
|
||||
|
||||
@@ -1,191 +1,201 @@
|
||||
<template>
|
||||
<v-card class="fc-tag-maint">
|
||||
<CardHeading icon="mdi-tag-remove" title="Tag maintenance" />
|
||||
<v-card-text>
|
||||
<p class="fc-muted text-body-2 mb-4">
|
||||
Remove tags with zero image associations and zero series-page
|
||||
references. Auto-created tag rows that never got applied get
|
||||
swept here.
|
||||
</p>
|
||||
<v-alert
|
||||
v-if="store.lastError"
|
||||
type="warning" variant="tonal" density="compact" class="mb-3"
|
||||
>{{ store.lastError }}</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-if="store.lastError"
|
||||
type="warning" variant="tonal" density="compact" class="mb-3"
|
||||
>{{ store.lastError }}</v-alert>
|
||||
<MaintenanceTile
|
||||
icon="mdi-tag-remove"
|
||||
title="Unused tags"
|
||||
blurb="Delete tags with no image or series-page references."
|
||||
destructive
|
||||
>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Remove tags with zero image associations and zero series-page
|
||||
references. Auto-created tag rows that never got applied get swept here.
|
||||
</p>
|
||||
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingPreview"
|
||||
class="mb-3"
|
||||
@click="onPreview"
|
||||
>Preview unused tags</v-btn>
|
||||
|
||||
<div v-if="preview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ preview.count }}</strong> unused tag(s).
|
||||
<span v-if="preview.count > 50" class="fc-muted">
|
||||
Showing first 50 names.
|
||||
</span>
|
||||
</p>
|
||||
<SampleNameGrid
|
||||
v-if="preview.sample_names?.length"
|
||||
:names="preview.sample_names" class="mb-3"
|
||||
/>
|
||||
<v-btn
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-delete-sweep"
|
||||
:disabled="!preview.count"
|
||||
:loading="committing"
|
||||
@click="onCommit"
|
||||
>Delete {{ preview.count }} unused tag(s)</v-btn>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-5" />
|
||||
|
||||
<p class="fc-muted text-body-2 mb-4">
|
||||
Purge legacy IR-migration tags FC no longer uses: retired/system
|
||||
kinds (<code>archive</code>, <code>post</code>,
|
||||
<code>artist</code> — e.g.
|
||||
<code>BlenderKnight:Hannah_BJ_Loops</code>) plus
|
||||
<code>source:*</code> tags (ImageRepo's old <code>source</code>
|
||||
kind, which migrated to <code>general</code>). Provenance and
|
||||
artists are their own systems now, so these are pure noise.
|
||||
Removes them from every image.
|
||||
</p>
|
||||
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingKindPreview"
|
||||
class="mb-3"
|
||||
@click="onKindPreview"
|
||||
>Preview legacy tags</v-btn>
|
||||
|
||||
<div v-if="kindPreview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ kindPreview.count }}</strong> legacy tag(s).
|
||||
<span v-for="(n, k) in kindPreview.by_kind" :key="k" class="fc-muted">
|
||||
{{ k }}: {{ n }}
|
||||
</span>
|
||||
<span v-for="(n, p) in kindPreview.by_prefix" :key="p" class="fc-muted">
|
||||
{{ p }}: {{ n }}
|
||||
</span>
|
||||
</p>
|
||||
<SampleNameGrid
|
||||
v-if="kindPreview.sample_names?.length"
|
||||
:names="kindPreview.sample_names" class="mb-3"
|
||||
/>
|
||||
<v-btn
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-delete-sweep"
|
||||
:disabled="!kindPreview.count"
|
||||
:loading="kindCommitting"
|
||||
@click="onKindCommit"
|
||||
>Delete {{ kindPreview.count }} legacy tag(s)</v-btn>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-5" />
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingPreview"
|
||||
class="mb-3"
|
||||
@click="onPreview"
|
||||
>Preview unused tags</v-btn>
|
||||
|
||||
<div v-if="preview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong class="text-error">Reset content tagging.</strong>
|
||||
Deletes every <code>general</code> and <code>character</code> tag and
|
||||
removes them from every image, so you can re-tag from scratch with the
|
||||
auto-suggest. <strong>Fandoms and series (with their page order) are
|
||||
kept</strong>, and each image's saved predictions are untouched — open
|
||||
an image and its suggestions reappear.
|
||||
<strong>{{ preview.count }}</strong> unused tag(s).
|
||||
<span v-if="preview.count > 50" class="fc-muted">Showing first 50 names.</span>
|
||||
</p>
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||
Irreversible — there's no undo except restoring a DB backup.
|
||||
Back one up first (Settings → Maintenance → Backup).
|
||||
</v-alert>
|
||||
|
||||
<SampleNameGrid
|
||||
v-if="preview.sample_names?.length"
|
||||
:names="preview.sample_names" class="mb-3"
|
||||
/>
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingResetPreview"
|
||||
class="mb-3"
|
||||
@click="onResetPreview"
|
||||
>Preview content-tag reset</v-btn>
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-delete-sweep"
|
||||
:disabled="!preview.count"
|
||||
:loading="committing"
|
||||
@click="onCommit"
|
||||
>Delete {{ preview.count }} unused tag(s)</v-btn>
|
||||
</div>
|
||||
</MaintenanceTile>
|
||||
|
||||
<div v-if="resetPreview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ resetPreview.count }}</strong> content tag(s)
|
||||
<span v-for="(n, k) in resetPreview.by_kind" :key="k" class="fc-muted">
|
||||
({{ k }}: {{ n }})
|
||||
</span>
|
||||
across <strong>{{ resetPreview.applications }}</strong> image
|
||||
application(s).
|
||||
</p>
|
||||
<SampleNameGrid
|
||||
v-if="resetPreview.sample_names?.length"
|
||||
:names="resetPreview.sample_names" class="mb-3"
|
||||
/>
|
||||
<v-btn
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-delete-alert"
|
||||
:disabled="!resetPreview.count"
|
||||
:loading="resetCommitting"
|
||||
@click="onResetCommit"
|
||||
>Delete {{ resetPreview.count }} content tag(s) +
|
||||
{{ resetPreview.applications }} application(s)</v-btn>
|
||||
</div>
|
||||
<MaintenanceTile
|
||||
icon="mdi-tag-off"
|
||||
title="Legacy migration tags"
|
||||
blurb="Purge retired archive/post/artist + source:* tags."
|
||||
destructive
|
||||
>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Purge legacy IR-migration tags FC no longer uses: retired/system
|
||||
kinds (<code>archive</code>, <code>post</code>, <code>artist</code> — e.g.
|
||||
<code>BlenderKnight:Hannah_BJ_Loops</code>) plus <code>source:*</code> tags
|
||||
(ImageRepo's old <code>source</code> kind, migrated to <code>general</code>).
|
||||
Provenance and artists are their own systems now, so these are pure noise.
|
||||
Removes them from every image.
|
||||
</p>
|
||||
|
||||
<v-divider class="my-5" />
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingKindPreview"
|
||||
class="mb-3"
|
||||
@click="onKindPreview"
|
||||
>Preview legacy tags</v-btn>
|
||||
|
||||
<!-- #714: standardize existing tags to the canonical Title-Case form
|
||||
and merge case/whitespace-variant duplicates. -->
|
||||
<div v-if="kindPreview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>Standardize tag casing.</strong>
|
||||
New tags are saved Title Case, but older tags keep whatever casing they
|
||||
were created with. This renames every existing tag to
|
||||
<code>Title Case</code> (collapsing extra spaces) and
|
||||
<strong>merges</strong> tags that differ only by case or spacing
|
||||
(e.g. <code>hatsune miku</code> + <code>Hatsune Miku</code>)
|
||||
into one — repointing their images, allowlist entries and series pages.
|
||||
</p>
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||
The merges are irreversible — back up first
|
||||
(Settings → Maintenance → Backup). Safe to run more than once.
|
||||
</v-alert>
|
||||
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingNormPreview"
|
||||
class="mb-3"
|
||||
@click="onNormPreview"
|
||||
>Preview tag standardization</v-btn>
|
||||
|
||||
<div v-if="normPreview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ normPreview.total_changes }}</strong> tag group(s) to
|
||||
change — <strong>{{ normPreview.tags_to_rename }}</strong> rename(s),
|
||||
<strong>{{ normPreview.collisions }}</strong> collision(s) merging
|
||||
<strong>{{ normPreview.tags_to_merge }}</strong> tag(s) away.
|
||||
</p>
|
||||
<SampleNameGrid v-if="normPreview.sample?.length" class="mb-3">
|
||||
<span
|
||||
v-for="s in normPreview.sample" :key="s.to + s.kind"
|
||||
class="fc-name"
|
||||
>
|
||||
<template v-if="s.merge">{{ s.from.join(' + ') }} → </template>{{ s.to }}
|
||||
</span>
|
||||
</SampleNameGrid>
|
||||
<v-btn
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-format-letter-case"
|
||||
:disabled="!normPreview.total_changes || normResult === 'queued'"
|
||||
:loading="normCommitting"
|
||||
@click="onNormCommit"
|
||||
>Standardize {{ normPreview.total_changes }} tag group(s)</v-btn>
|
||||
<span v-if="normResult === 'queued'" class="ml-3 text-caption text-success">
|
||||
Queued ✓ — runs in the background (you can leave this page). It
|
||||
processes in chunks, so re-run “Preview” later to confirm it's all done.
|
||||
<strong>{{ kindPreview.count }}</strong> legacy tag(s).
|
||||
<span v-for="(n, k) in kindPreview.by_kind" :key="k" class="fc-muted">
|
||||
{{ k }}: {{ n }}
|
||||
</span>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<span v-for="(n, p) in kindPreview.by_prefix" :key="p" class="fc-muted">
|
||||
{{ p }}: {{ n }}
|
||||
</span>
|
||||
</p>
|
||||
<SampleNameGrid
|
||||
v-if="kindPreview.sample_names?.length"
|
||||
:names="kindPreview.sample_names" class="mb-3"
|
||||
/>
|
||||
<v-btn
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-delete-sweep"
|
||||
:disabled="!kindPreview.count"
|
||||
:loading="kindCommitting"
|
||||
@click="onKindCommit"
|
||||
>Delete {{ kindPreview.count }} legacy tag(s)</v-btn>
|
||||
</div>
|
||||
</MaintenanceTile>
|
||||
|
||||
<MaintenanceTile
|
||||
icon="mdi-tag-multiple"
|
||||
title="Reset content tagging"
|
||||
blurb="Delete all general/character tags to re-tag from scratch."
|
||||
destructive
|
||||
>
|
||||
<p class="text-body-2 mb-2">
|
||||
Deletes every <code>general</code> and <code>character</code> tag and
|
||||
removes them from every image, so you can re-tag from scratch with the
|
||||
auto-suggest. <strong>Fandoms and series (with their page order) are
|
||||
kept</strong>, and each image's saved predictions are untouched — open
|
||||
an image and its suggestions reappear.
|
||||
</p>
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||
Irreversible — there's no undo except restoring a DB backup.
|
||||
Back one up first (Settings → Maintenance → Backup).
|
||||
</v-alert>
|
||||
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingResetPreview"
|
||||
class="mb-3"
|
||||
@click="onResetPreview"
|
||||
>Preview content-tag reset</v-btn>
|
||||
|
||||
<div v-if="resetPreview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ resetPreview.count }}</strong> content tag(s)
|
||||
<span v-for="(n, k) in resetPreview.by_kind" :key="k" class="fc-muted">
|
||||
({{ k }}: {{ n }})
|
||||
</span>
|
||||
across <strong>{{ resetPreview.applications }}</strong> image
|
||||
application(s).
|
||||
</p>
|
||||
<SampleNameGrid
|
||||
v-if="resetPreview.sample_names?.length"
|
||||
:names="resetPreview.sample_names" class="mb-3"
|
||||
/>
|
||||
<v-btn
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-delete-alert"
|
||||
:disabled="!resetPreview.count"
|
||||
:loading="resetCommitting"
|
||||
@click="onResetCommit"
|
||||
>Delete {{ resetPreview.count }} content tag(s) +
|
||||
{{ resetPreview.applications }} application(s)</v-btn>
|
||||
</div>
|
||||
</MaintenanceTile>
|
||||
|
||||
<MaintenanceTile
|
||||
icon="mdi-format-letter-case"
|
||||
title="Standardize tag casing"
|
||||
blurb="Title-case all tags & merge case/space-variant duplicates."
|
||||
destructive
|
||||
>
|
||||
<!-- #714: standardize existing tags to the canonical Title-Case form
|
||||
and merge case/whitespace-variant duplicates. -->
|
||||
<p class="text-body-2 mb-2">
|
||||
New tags are saved Title Case, but older tags keep whatever casing they
|
||||
were created with. This renames every existing tag to <code>Title Case</code>
|
||||
(collapsing extra spaces) and <strong>merges</strong> tags that differ only
|
||||
by case or spacing (e.g. <code>hatsune miku</code> +
|
||||
<code>Hatsune Miku</code>) into one — repointing their images,
|
||||
allowlist entries and series pages.
|
||||
</p>
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||
The merges are irreversible — back up first
|
||||
(Settings → Maintenance → Backup). Safe to run more than once.
|
||||
</v-alert>
|
||||
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingNormPreview"
|
||||
class="mb-3"
|
||||
@click="onNormPreview"
|
||||
>Preview tag standardization</v-btn>
|
||||
|
||||
<div v-if="normPreview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ normPreview.total_changes }}</strong> tag group(s) to
|
||||
change — <strong>{{ normPreview.tags_to_rename }}</strong> rename(s),
|
||||
<strong>{{ normPreview.collisions }}</strong> collision(s) merging
|
||||
<strong>{{ normPreview.tags_to_merge }}</strong> tag(s) away.
|
||||
</p>
|
||||
<SampleNameGrid v-if="normPreview.sample?.length" class="mb-3">
|
||||
<span
|
||||
v-for="s in normPreview.sample" :key="s.to + s.kind"
|
||||
class="fc-name"
|
||||
>
|
||||
<template v-if="s.merge">{{ s.from.join(' + ') }} → </template>{{ s.to }}
|
||||
</span>
|
||||
</SampleNameGrid>
|
||||
<v-btn
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-format-letter-case"
|
||||
:disabled="!normPreview.total_changes || normResult === 'queued'"
|
||||
:loading="normCommitting"
|
||||
@click="onNormCommit"
|
||||
>Standardize {{ normPreview.total_changes }} tag group(s)</v-btn>
|
||||
<span v-if="normResult === 'queued'" class="ml-3 text-caption text-success">
|
||||
Queued ✓ — runs in the background (you can leave this page). It
|
||||
processes in chunks, so re-run “Preview” later to confirm it's all done.
|
||||
</span>
|
||||
</div>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -193,7 +203,7 @@ import { ref } from 'vue'
|
||||
|
||||
import { useAdminStore } from '../../stores/admin.js'
|
||||
import SampleNameGrid from '../common/SampleNameGrid.vue'
|
||||
import CardHeading from '../common/CardHeading.vue'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
|
||||
const store = useAdminStore()
|
||||
const preview = ref(null)
|
||||
@@ -287,17 +297,8 @@ async function onNormCommit() {
|
||||
// confirm it's queued; the operator can re-run Preview later to verify.
|
||||
await store.normalizeTags({ dryRun: false })
|
||||
normResult.value = 'queued'
|
||||
// Keep the preview showing what was QUEUED — do NOT zero it out. Overwriting
|
||||
// it with a zeroed object made the card read "0 tag groups to change" the
|
||||
// instant Standardize was clicked, which looked like nothing happened
|
||||
// (operator-flagged 2026-06-07). The button disables on `queued`; re-running
|
||||
// Preview later reflects the real remaining count as the task applies.
|
||||
} finally {
|
||||
normCommitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-tag-maint { border-radius: 8px; }
|
||||
</style>
|
||||
|
||||
@@ -3,54 +3,57 @@
|
||||
duration + aspect (the same clip re-encoded / pulled from multiple
|
||||
sources). Preview first, then apply (destructive: removes the redundant
|
||||
copies, keeping the highest-resolution one). -->
|
||||
<v-card>
|
||||
<v-card-title>Deduplicate videos</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-3">
|
||||
Finds videos of the same artist that are the same content across
|
||||
re-encodes (matching duration + aspect ratio) and keeps the
|
||||
highest-resolution copy. <strong>Preview</strong> first to see what would
|
||||
be removed; <strong>Apply</strong> re-points each post to the kept copy,
|
||||
then deletes the redundant videos and their files. The first run also
|
||||
computes durations for older videos, so it may take a while.
|
||||
</p>
|
||||
<MaintenanceTile
|
||||
icon="mdi-content-duplicate"
|
||||
title="Deduplicate videos"
|
||||
blurb="Collapse re-encoded duplicate videos, keeping the best copy."
|
||||
destructive
|
||||
:open="applying || previewing"
|
||||
>
|
||||
<p class="text-body-2 mb-3">
|
||||
Finds videos of the same artist that are the same content across
|
||||
re-encodes (matching duration + aspect ratio) and keeps the
|
||||
highest-resolution copy. <strong>Preview</strong> first to see what would
|
||||
be removed; <strong>Apply</strong> re-points each post to the kept copy,
|
||||
then deletes the redundant videos and their files. The first run also
|
||||
computes durations for older videos, so it may take a while.
|
||||
</p>
|
||||
|
||||
<div class="d-flex align-center flex-wrap" style="gap: 12px;">
|
||||
<v-btn
|
||||
color="primary" variant="tonal" rounded="pill"
|
||||
:loading="previewing" :disabled="applying" @click="preview"
|
||||
>
|
||||
<v-icon start>mdi-magnify</v-icon> Preview
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error" rounded="pill"
|
||||
:loading="applying"
|
||||
:disabled="previewing || !canApply"
|
||||
@click="confirmOpen = true"
|
||||
>
|
||||
<v-icon start>mdi-content-duplicate</v-icon> Apply
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="summary" :type="summaryType" variant="tonal" class="mt-4"
|
||||
density="comfortable"
|
||||
<div class="d-flex align-center flex-wrap" style="gap: 12px;">
|
||||
<v-btn
|
||||
color="primary" variant="tonal" rounded="pill"
|
||||
:loading="previewing" :disabled="applying" @click="preview"
|
||||
>
|
||||
<span v-if="applied">
|
||||
Removed {{ summary.deleted }} redundant video(s) across
|
||||
{{ summary.groups }} group(s); re-pointed {{ summary.relinked_posts }}
|
||||
post link(s); reclaimed {{ humanBytes(summary.reclaim_bytes) }}.
|
||||
</span>
|
||||
<span v-else-if="summary.redundant > 0">
|
||||
{{ summary.redundant }} redundant video(s) across {{ summary.groups }}
|
||||
group(s) — {{ humanBytes(summary.reclaim_bytes) }} reclaimable. Click
|
||||
<strong>Apply</strong> to remove them (keeping the best copy).
|
||||
</span>
|
||||
<span v-else>No duplicate videos found.</span>
|
||||
</v-alert>
|
||||
<v-icon start>mdi-magnify</v-icon> Preview
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error" rounded="pill"
|
||||
:loading="applying"
|
||||
:disabled="previewing || !canApply"
|
||||
@click="confirmOpen = true"
|
||||
>
|
||||
<v-icon start>mdi-content-duplicate</v-icon> Apply
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance" />
|
||||
</v-card-text>
|
||||
<v-alert
|
||||
v-if="summary" :type="summaryType" variant="tonal" class="mt-4"
|
||||
density="comfortable"
|
||||
>
|
||||
<span v-if="applied">
|
||||
Removed {{ summary.deleted }} redundant video(s) across
|
||||
{{ summary.groups }} group(s); re-pointed {{ summary.relinked_posts }}
|
||||
post link(s); reclaimed {{ humanBytes(summary.reclaim_bytes) }}.
|
||||
</span>
|
||||
<span v-else-if="summary.redundant > 0">
|
||||
{{ summary.redundant }} redundant video(s) across {{ summary.groups }}
|
||||
group(s) — {{ humanBytes(summary.reclaim_bytes) }} reclaimable. Click
|
||||
<strong>Apply</strong> to remove them (keeping the best copy).
|
||||
</span>
|
||||
<span v-else>No duplicate videos found.</span>
|
||||
</v-alert>
|
||||
|
||||
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance" />
|
||||
|
||||
<v-dialog v-model="confirmOpen" max-width="440">
|
||||
<v-card>
|
||||
@@ -68,13 +71,14 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-card>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import QueueStatusBar from './QueueStatusBar.vue'
|
||||
|
||||
const confirmOpen = ref(false)
|
||||
|
||||
Reference in New Issue
Block a user