fix(provenance): scope attachments to originating post + scroll-cap the list
The Attachments section aggregated PostAttachment rows across EVERY post an image was pHash-linked to. When one of those was a 'High Resolution Files' mega-bundle (dozens of unrelated archives), the list ballooned past the viewport and overwhelmed the modal's right rail. - for_image() now scopes attachments to ImageRecord.primary_post_id (the post the file was actually captured from), falling back to all linked posts only when primary_post_id is unset (older rows / filesystem imports). - ProvenancePanel wraps the list in a max-height scroll container with a count in the heading, mirroring the cards' independent-scroll treatment. Note: FC stores archives as opaque blobs and never records which archive an extracted image came from, so attachments can't yet be scoped tighter than the post. Capturing image->archive containment is tracked as separate work. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,7 +85,22 @@ class ProvenanceService:
|
||||
)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
post_ids = [ip.post_id for ip, _p, _s, _a in rows]
|
||||
attachments = await self._attachments_for_posts(post_ids)
|
||||
# Scope attachments to the image's ORIGINATING post only, not every
|
||||
# post it's pHash-linked to. An image dupe gets a provenance row per
|
||||
# post it reappears in (enrich-on-duplicate), and one of those is
|
||||
# often a "High Resolution Files" mega-bundle carrying dozens of
|
||||
# unrelated archives — aggregating across all linked posts ballooned
|
||||
# the panel with files that have nothing to do with this image.
|
||||
# primary_post_id is the post this file was actually captured from;
|
||||
# fall back to all linked posts only when it's unset (older rows /
|
||||
# filesystem imports). NB: FC stores archives as opaque blobs and
|
||||
# never records which archive an extracted image came from, so we
|
||||
# cannot scope tighter than the post — see milestone for the
|
||||
# image->archive capture work.
|
||||
attach_post_ids = (
|
||||
[rec.primary_post_id] if rec.primary_post_id is not None else post_ids
|
||||
)
|
||||
attachments = await self._attachments_for_posts(attach_post_ids)
|
||||
return {
|
||||
"image_id": image_id,
|
||||
"provenance": [
|
||||
|
||||
@@ -70,14 +70,19 @@
|
||||
</div>
|
||||
|
||||
<div v-if="attachments.length" class="fc-prov__attach">
|
||||
<h4 class="fc-prov__attach-title">Attachments</h4>
|
||||
<a
|
||||
v-for="at in attachments" :key="at.id"
|
||||
class="fc-prov__attach-row"
|
||||
:href="at.download_url" :download="at.original_filename"
|
||||
>⬇ {{ at.original_filename }}
|
||||
<span class="fc-prov__attach-size">({{ at.size_bytes }} B)</span>
|
||||
</a>
|
||||
<h4 class="fc-prov__attach-title">Attachments ({{ attachments.length }})</h4>
|
||||
<!-- Scroll-capped: a single post can carry dozens of archives (HR
|
||||
bundle posts), which previously ballooned the panel past the
|
||||
viewport. Mirror the cards' independent-scroll treatment. -->
|
||||
<div class="fc-prov__attach-list">
|
||||
<a
|
||||
v-for="at in attachments" :key="at.id"
|
||||
class="fc-prov__attach-row"
|
||||
:href="at.download_url" :download="at.original_filename"
|
||||
>⬇ {{ at.original_filename }}
|
||||
<span class="fc-prov__attach-size">({{ at.size_bytes }} B)</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -229,6 +234,15 @@ function openPost(postId, artistId) {
|
||||
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
.fc-prov__attach-list {
|
||||
/* ~7 rows visible before scrolling; the clipped row hints at more.
|
||||
Hairline scrollbar matching .fc-prov__cards. */
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(var(--v-theme-surface-light)) transparent;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.fc-prov__attach-row {
|
||||
display: block; font-size: 13px; text-decoration: none;
|
||||
color: rgb(var(--v-theme-accent)); padding: 2px 0;
|
||||
|
||||
@@ -134,6 +134,67 @@ async def test_for_image_null_post_fields_serialize_null(db):
|
||||
assert e["post"]["attachment_count"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_for_image_attachments_scoped_to_primary_post(db):
|
||||
# Image linked to TWO posts; only the primary (originating) post's
|
||||
# attachments should surface — not the other post's mega-bundle.
|
||||
rec = await _seed_image(db)
|
||||
a1, s1, primary = await _seed_post(db, artist_name="P1", slug="p1",
|
||||
platform="patreon", ext_id="100")
|
||||
a2, s2, bundle = await _seed_post(db, artist_name="P2", slug="p2",
|
||||
platform="patreon", ext_id="200")
|
||||
rec.primary_post_id = primary.id
|
||||
db.add(ImageProvenance(image_record_id=rec.id, post_id=primary.id,
|
||||
source_id=s1.id))
|
||||
db.add(ImageProvenance(image_record_id=rec.id, post_id=bundle.id,
|
||||
source_id=s2.id))
|
||||
db.add(PostAttachment(
|
||||
post_id=primary.id, artist_id=a1.id, sha256="p" + "0" * 63,
|
||||
path="/images/attachments/p00/keep.zip", original_filename="keep.zip",
|
||||
ext=".zip", mime="application/zip", size_bytes=9,
|
||||
))
|
||||
db.add(PostAttachment(
|
||||
post_id=bundle.id, artist_id=a2.id, sha256="b" + "0" * 63,
|
||||
path="/images/attachments/b00/drop.rar", original_filename="drop.rar",
|
||||
ext=".rar", mime="application/x-rar", size_bytes=9,
|
||||
))
|
||||
await db.flush()
|
||||
|
||||
payload = await ProvenanceService(db).for_image(rec.id)
|
||||
names = [a["original_filename"] for a in payload["attachments"]]
|
||||
assert names == ["keep.zip"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_for_image_attachments_fallback_to_all_posts_when_no_primary(db):
|
||||
# No primary_post_id (older rows / filesystem imports) → preserve the
|
||||
# aggregate-across-linked-posts behavior so attachments aren't lost.
|
||||
rec = await _seed_image(db)
|
||||
a1, s1, p1 = await _seed_post(db, artist_name="F1", slug="f1",
|
||||
platform="patreon", ext_id="300")
|
||||
a2, s2, p2 = await _seed_post(db, artist_name="F2", slug="f2",
|
||||
platform="patreon", ext_id="400")
|
||||
db.add(ImageProvenance(image_record_id=rec.id, post_id=p1.id,
|
||||
source_id=s1.id))
|
||||
db.add(ImageProvenance(image_record_id=rec.id, post_id=p2.id,
|
||||
source_id=s2.id))
|
||||
db.add(PostAttachment(
|
||||
post_id=p1.id, artist_id=a1.id, sha256="c" + "0" * 63,
|
||||
path="/images/attachments/c00/one.zip", original_filename="one.zip",
|
||||
ext=".zip", mime="application/zip", size_bytes=9,
|
||||
))
|
||||
db.add(PostAttachment(
|
||||
post_id=p2.id, artist_id=a2.id, sha256="d" + "0" * 63,
|
||||
path="/images/attachments/d00/two.zip", original_filename="two.zip",
|
||||
ext=".zip", mime="application/zip", size_bytes=9,
|
||||
))
|
||||
await db.flush()
|
||||
|
||||
payload = await ProvenanceService(db).for_image(rec.id)
|
||||
names = sorted(a["original_filename"] for a in payload["attachments"])
|
||||
assert names == ["one.zip", "two.zip"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_for_post_missing_returns_none(db):
|
||||
svc = ProvenanceService(db)
|
||||
|
||||
Reference in New Issue
Block a user