fix(posts): link duplicate items to every post + prune bare shells
The native Patreon backfill flooded the feed with bare 'Post <id>' shells (1589 for Anduo). Root cause: PostAttachment.sha256 was GLOBALLY unique, so a non-art file reused across posts only ever linked to the first one, and _capture_attachment created the Post before that dedup check — leaving later posts with no image and no attachment. Duplicate IMAGES had the mirror gap: attach_in_place returned duplicate_hash/duplicate_phash before _apply_sidecar, so the second post got no provenance row, and the feed only rendered via primary_post_id (one post per image). Operator requirement: a duplicate item must show on EVERY post it appears in. Unify the fix as link-not-suppress: - importer: on duplicate_hash / duplicate_phash(larger_exists), append an image_provenance row for the new post (keep primary on the first). Both the download path (attach_in_place) and the filesystem path (_import_media). - post_feed_service: render thumbnails by image_provenance UNION primary_post_id, so a cross-posted image shows on every post (and legacy primary-only images still show). - PostAttachment: per-post uniqueness — drop UNIQUE(sha256), add partial UNIQUE(post_id, sha256) + partial UNIQUE(sha256) WHERE post_id IS NULL (migration 0043); _capture_attachment dedups per-(post,sha) over the shared sha-addressed blob, so no post is left bare. - cleanup: new prune-bare-posts maintenance action (cleanup_service _bare_post_conditions shared by preview/count/delete per preview/apply parity; admin endpoint; PostMaintenanceCard). Deletes posts with zero image links (primary or provenance) AND zero attachments. Run after the feed fix so a hidden provenance link spares the post instead of deleting it. Tests: dup image shows on both posts; dup attachment shows on both posts; feed renders provenance-linked duplicates; prune-bare delete-path == preview. Operator redeploys (migration 0043) then runs the prune to clear the shells. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<v-card class="fc-post-maint">
|
||||
<v-card-title class="d-flex align-center" style="gap: 10px;">
|
||||
<v-icon icon="mdi-post-outline" size="small" />
|
||||
<span>Post maintenance</span>
|
||||
</v-card-title>
|
||||
<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>
|
||||
<div v-if="preview.sample_names?.length" class="fc-name-grid mb-3">
|
||||
<span v-for="(n, i) in preview.sample_names" :key="i" class="fc-name">
|
||||
{{ n }}
|
||||
</span>
|
||||
</div>
|
||||
<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-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useAdminStore } from '../../stores/admin.js'
|
||||
|
||||
const store = useAdminStore()
|
||||
const preview = ref(null)
|
||||
const loadingPreview = ref(false)
|
||||
const committing = ref(false)
|
||||
const deleted = ref(null)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-post-maint { border-radius: 8px; }
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-name-grid {
|
||||
display: flex; flex-wrap: wrap; gap: 4px 8px;
|
||||
max-height: 200px; overflow-y: auto;
|
||||
padding: 8px; border-radius: 4px;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
.fc-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user