Files
FabledCurator/frontend/src/components/modal/ProvenancePanel.vue
T
bvandeusen 2f66de2928
CI / lint (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 17s
CI / intimp (push) Failing after 2m15s
CI / intapi (push) Failing after 2m18s
CI / intcore (push) Failing after 2m17s
feat(model): nullable Post.source_id + denormalized Post.artist_id; retire sidecar synthetics
Operator-asked 2026-06-01 after the Dymkens orphan investigation
(Scribe plan #540). The pre-2030 sidecar-synthetic Source pattern
(`sidecar:<platform>:<slug>` enabled=false rows) existed solely to
satisfy `Post.source_id NOT NULL`, and leaked into the Subscriptions
UI as phantom subscriptions. Now the data model says what's true:
filesystem-imported content with no live subscription has NULL
source_id, full stop.

## Schema (alembic 0030)

- `post.artist_id` — NEW NOT NULL FK to artist (CASCADE). Backfilled
  from source.artist_id in the migration. Indexed for the artist-filter
  queries.
- `post.source_id` — NOT NULL → nullable; FK ondelete CASCADE → SET
  NULL. Deleting a Source detaches its Posts instead of destroying
  archived content (subscription ends, archive stays).
- `image_provenance.source_id` — same nullable + SET NULL.
- Partial unique index `uq_post_artist_external_id_null_source` on
  (artist_id, external_post_id) WHERE source_id IS NULL — guards
  filesystem-import dedup since the existing source-bound unique
  ignores NULLs (Postgres treats NULL != NULL).
- Sidecar synthetic Sources deleted: NULL out FKs in post,
  image_provenance first, then DELETE FROM source WHERE url LIKE
  'sidecar:%'. The Dymkens cleanup.

## Model + service changes

- `Post.source_id` → `Mapped[int | None]`; new `Post.artist_id`
  denormalized.
- `ImageProvenance.source_id` → `Mapped[int | None]`.
- Importer: `_source_for_sidecar` (synthetic-creating) →
  `_lookup_source_for_sidecar` (returns None when no subscription).
  `_find_or_create_post` takes required `artist_id`; matches on
  (source_id, external_post_id) for source-bound posts or
  (artist_id, external_post_id) for NULL-source posts.
- Service queries switched off the Source detour to use Post.artist_id
  directly: post_feed_service.scroll/around/get_post (LEFT JOIN to
  Source so NULL-source posts surface); artist_service date_row/
  activity/post_count; provenance_service.for_image/for_post (LEFT
  JOIN); gallery_service._provenance_exists_where_artist via
  Post.artist_id instead of ImageProvenance.source_id → Source.
- `_to_dict` and provenance dict-builders emit `"source": null` for
  NULL-source rows.

## Frontend

- `ProvenancePanel.vue` + `PostCard.vue`: render `e.source?.platform
  ?? 'filesystem import'` so NULL-source posts get a clear
  "filesystem import" affordance instead of a NaN crash.

## Tests

- `test_importer_upsert_helpers`: removed the four synthetic-anchor
  tests; added `_find_or_create_post_idempotent_with_null_source`
  (dedup via the partial unique index) and
  `_lookup_source_for_sidecar_returns_*` (existing-subscription +
  none cases). The existing `_find_or_create_post_idempotent` now
  also passes `artist_id` and asserts it.
- 8 other test files updated: every direct `Post(...)` construction
  gains `artist_id=<artist>.id`. The `_seed_post` helper in
  `test_post_feed_service` looks up artist_id from the source row so
  callsites stay one-arg.

## Verification on deploy

After alembic 0030 runs:
- `SELECT COUNT(*) FROM source WHERE url LIKE 'sidecar:%'` → 0.
- `SELECT COUNT(*) FROM post WHERE source_id IS NULL` → count of
  filesystem-imported posts (Dymkens + any other historical).
- Every `post.artist_id` non-null; consistent with source.artist_id
  for source-bound rows.
- Subscriptions tab: no Dymkens phantom row.
- Artist detail → Posts/Gallery: Dymkens's content still reachable
  via Post.artist_id.
- Provenance panel renders "filesystem import" chip for NULL-source
  posts; PostCard same.

## Out of scope

- UI to manage/delete orphan NULL-source Posts. Data model is right;
  UI follows if operator wants it.
2026-06-01 14:17:52 -04:00

238 lines
8.6 KiB
Vue

<template>
<section v-if="show" class="fc-prov" aria-label="Provenance">
<h3 class="fc-prov__title">Provenance</h3>
<v-progress-circular
v-if="state && state.loading" indeterminate color="accent" size="22"
/>
<v-alert
v-else-if="state && state.error" type="error" variant="tonal"
density="compact"
>{{ state.error }}</v-alert>
<!-- Cards scroll independently of the section title + attachments
below them. Cap at ~2.5 cards visible (operator-asked 2026-06-01:
keeps the Tags section anchored below at a consistent position;
the half-visible third card hints there's more). -->
<div v-else class="fc-prov__cards">
<article
v-for="e in state.entries" :key="e.provenance_id" class="fc-prov__card"
>
<div class="fc-prov__head">
<!-- Posts with no live subscription have source=null (alembic
0030); render an explicit "filesystem import" affordance
instead of a platform chip. -->
<span class="fc-prov__platform">
{{ e.source?.platform ?? 'filesystem import' }}
</span>
<span v-if="postDate(e)" class="fc-prov__date">{{ postDate(e) }}</span>
</div>
<button
type="button" class="fc-prov__post"
:title="`Open ${postTitle(e)} in the posts feed for ${e.artist.name}`"
@click="openPost(e.post.id, e.artist.id)"
>
{{ postTitle(e) }}
</button>
<div class="fc-prov__meta">
<RouterLink :to="`/artist/${e.artist.slug}`">
by {{ e.artist.name }}
</RouterLink>
<span v-if="e.post.attachment_count != null">
· {{ e.post.attachment_count }} files
</span>
</div>
<div v-if="e.post.description_html" class="fc-prov__actions">
<a
href="#" @click.prevent="toggleDesc(e.provenance_id)"
>{{ expanded[e.provenance_id] ? 'Hide description ' : 'Show description ' }}</a>
</div>
<div
v-if="e.post.description_html && expanded[e.provenance_id]"
class="fc-prov__desc" v-html="e.post.description_html"
/>
</article>
<!-- Fallback: image has artist_id set (folder-derived from
filesystem import or set by tag_apply) but no ImageProvenance
row exists. Show a minimal "by <artist>" line so the modal
isn't blank for IR-imported images that aren't in any post. -->
<article
v-if="showArtistFallback"
class="fc-prov__card"
>
<div class="fc-prov__meta">
<RouterLink :to="`/artist/${fallbackArtist.slug}`">
by {{ fallbackArtist.name }}
</RouterLink>
</div>
</article>
</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>
</div>
</section>
</template>
<script setup>
import { computed, reactive, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useModalStore } from '../../stores/modal.js'
import { useProvenanceStore } from '../../stores/provenance.js'
import { formatPostDate } from '../../utils/date.js'
import { toPlainText } from '../../utils/htmlSanitize.js'
const modal = useModalStore()
const prov = useProvenanceStore()
const router = useRouter()
// Per-post description collapse state (keyed by provenance_id). Default
// collapsed so multiple posts don't each eat ~180px of the panel the
// operator flagged the descriptions consuming a lot of real estate
// 2026-05-28. Reset when the viewed image changes.
const expanded = reactive({})
function toggleDesc(id) { expanded[id] = !expanded[id] }
watch(() => modal.currentImageId, () => {
for (const k of Object.keys(expanded)) delete expanded[k]
})
watch(
() => modal.currentImageId,
(id) => { if (id != null) prov.loadForImage(id) },
{ immediate: true }
)
const state = computed(() =>
modal.currentImageId == null ? null : prov.imageProv(modal.currentImageId)
)
const fallbackArtist = computed(() => modal.current?.artist || null)
const showArtistFallback = computed(() => {
const st = state.value
// Only show the fallback when provenance is genuinely empty (no
// entries, not loading, no error) AND the image has a direct
// artist_id we can surface.
if (!st || st.loading || st.error) return false
if (Array.isArray(st.entries) && st.entries.length > 0) return false
return fallbackArtist.value != null
})
// Show the panel while loading or on error, when there is >=1 entry,
// or when the artist fallback applies (image_record.artist_id set but
// no ImageProvenance row, e.g. filesystem-imported IR images).
const show = computed(() => {
const st = state.value
if (!st) return false
if (st.loading || st.error) return true
if (Array.isArray(st.entries) && st.entries.length > 0) return true
return showArtistFallback.value
})
const attachments = computed(() => state.value?.attachments || [])
function postDate(e) { return formatPostDate(e.post.date) }
function postTitle(e) {
// Titles can arrive as stored HTML (e.g. "<strong>…</strong>"); render
// as plain text (the CSS makes it bold).
return toPlainText(e.post.title) || `Post ${e.post.external_post_id}`
}
function openPost(postId, artistId) {
// Land on the post in the posts feed (in context), not the gallery
// image grid. Scope the feed to this artist so the user lands in
// that creator's stream, not the global one — operator-flagged
// 2026-06-01. PostsView reads `artist_id` from the query string
// (PostsView.vue line ~92) and filters via post_feed_service.
router.push({
path: '/posts',
query: { post_id: postId, artist_id: artistId },
})
modal.close()
}
</script>
<style scoped>
.fc-prov { padding: 16px 16px 0; }
.fc-prov__title {
font-family: 'Fraunces', Georgia, serif;
font-size: 18px; font-weight: 500;
color: rgb(var(--v-theme-on-surface));
margin-bottom: 12px;
}
.fc-prov__cards {
/* 2.5 cards-worth at the typical collapsed card height (~108px each
incl. 10px gap). Slightly under to ensure the third card's bottom
edge is clipped — the visual cue that there's more below. */
max-height: 270px;
overflow-y: auto;
/* Hairline scrollbar that doesn't compete with content. */
scrollbar-width: thin;
scrollbar-color: rgb(var(--v-theme-surface-light)) transparent;
/* Pad-right so the scrollbar gutter doesn't squeeze card borders. */
padding-right: 4px;
}
.fc-prov__card {
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 6px; padding: 10px 12px; margin-bottom: 10px;
}
.fc-prov__head {
display: flex; justify-content: space-between; align-items: baseline;
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
text-transform: lowercase;
}
.fc-prov__post {
/* Clickable title — opens the post in the artist-scoped feed
(operator-flagged 2026-06-01: title IS the primary action, the
prior "View post" link was redundant). Styled as a button-link:
accent color, underline on hover, focus ring for keyboard nav. */
display: block; width: 100%; text-align: left;
background: none; border: none; padding: 0;
font: inherit; font-weight: 700;
margin: 4px 0;
color: rgb(var(--v-theme-accent));
cursor: pointer;
}
.fc-prov__post:hover { text-decoration: underline; }
.fc-prov__post:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 2px; border-radius: 3px;
}
.fc-prov__meta {
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
display: flex; gap: 6px; flex-wrap: wrap;
}
.fc-prov__meta a { color: rgb(var(--v-theme-accent)); text-decoration: none; }
.fc-prov__actions {
display: flex; gap: 12px; flex-wrap: wrap;
margin-top: 6px; font-size: 13px;
}
.fc-prov__actions a {
color: rgb(var(--v-theme-accent)); text-decoration: none;
}
.fc-prov__desc {
margin-top: 8px; font-size: 13px; line-height: 1.5;
max-height: 180px; overflow: auto;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-prov__desc :deep(a) { color: rgb(var(--v-theme-accent)); }
.fc-prov__attach { padding: 0 0 12px; }
.fc-prov__attach-title {
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
margin: 8px 0 4px;
}
.fc-prov__attach-row {
display: block; font-size: 13px; text-decoration: none;
color: rgb(var(--v-theme-accent)); padding: 2px 0;
}
.fc-prov__attach-size { color: rgb(var(--v-theme-on-surface-variant)); }
</style>