Files
FabledCurator/frontend/src/components/settings/PostMaintenanceCard.vue
T
bvandeusen eff64275fc
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 38s
CI / integration (push) Successful in 3m17s
feat(maintenance): reconcile duplicate posts (gallery-dl→native unify)
An artist first downloaded by gallery-dl gets Post rows keyed by the per-
attachment id; a later native walk keys the SAME real post by the post id. They
never dedup (uq_post_source_external_id is on external_post_id) → duplicate post
rows (cheunart: 943→1109). The real post id is recoverable in-DB from
raw_metadata['post_id'] (both eras store the sidecar there).

reconcile_duplicate_posts (cleanup_service): group posts by (source_id, canonical
post_id = raw_metadata.post_id else external_post_id); for each group >1, keep the
row already keyed by the post id (the format the CURRENT native downloader
produces, so future walks dedup and this can't recur), re-point
ImageRecord.primary_post_id / ImageProvenance / PostAttachment / ExternalLink onto
it conflict-safe (drop the loser's row where the keeper already has the equivalent,
per each table's uniqueness), backfill the keeper's empty date/title/body/raw_meta
from a loser, set external_post_id=post_id + derive post_url, delete losers.
IMAGES ARE NOT TOUCHED (content-addressed/deduped already; operator-confirmed).

Preview/apply share find_duplicate_post_groups (rule 93). API
/api/admin/posts/reconcile-duplicates (dry_run→{groups,posts_to_merge,sample};
apply→{groups,merged,sample}; optional source_id). UI: a second section on
PostMaintenanceCard (preview groups+sample → confirm merge). Tests: merge +
metadata backfill + image move, no-op when unique, provenance-collision dedup.

Design: milestone #73. Forensics: note #917. Out of scope (flagged): cheunart vs
Cheunart case-variant artist dirs/rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:57:21 -04:00

172 lines
5.4 KiB
Vue

<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.
</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>
<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>
<v-divider class="my-5" />
<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.
</p>
<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>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useAdminStore } from '../../stores/admin.js'
import SampleNameGrid from '../common/SampleNameGrid.vue'
import CardHeading from '../common/CardHeading.vue'
const store = useAdminStore()
const preview = ref(null)
const loadingPreview = ref(false)
const committing = ref(false)
const deleted = ref(null)
const dupPreview = ref(null)
const loadingDupPreview = ref(false)
const merging = ref(false)
const merged = ref(null)
const dupSampleNames = computed(() =>
(dupPreview.value?.sample ?? []).map(
(g) => `${g.title}${g.rows} rows`,
),
)
async function onPreview() {
loadingPreview.value = true
deleted.value = null
try {
preview.value = await store.pruneBarePosts({ dryRun: true })
} finally {
loadingPreview.value = false
}
}
async function onCommit() {
committing.value = true
try {
const result = await store.pruneBarePosts({ dryRun: false })
deleted.value = result.deleted ?? 0
// Reflect the completed sweep — the predicate is identical to the preview,
// so after a commit there is nothing left to delete.
preview.value = { count: 0, sample_names: [] }
} finally {
committing.value = false
}
}
async function onPreviewDupes() {
loadingDupPreview.value = true
merged.value = null
try {
dupPreview.value = await store.reconcileDuplicatePosts({ dryRun: true })
} finally {
loadingDupPreview.value = false
}
}
async function onMergeDupes() {
merging.value = true
try {
const result = await store.reconcileDuplicatePosts({ dryRun: false })
merged.value = result.merged ?? 0
// Same predicate as the preview — after the merge there are no dup groups left.
dupPreview.value = { groups: 0, posts_to_merge: 0, sample: [] }
} finally {
merging.value = false
}
}
</script>
<style scoped>
.fc-post-maint { border-radius: 8px; }
</style>