1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-10-09 12:55:00 +02:00

fix: keep text previews and summary state scoped to entry

This commit is contained in:
archivr-qa 2026-08-24 15:29:22 +02:00
parent 492b1a3168
commit 222d237044
No known key found for this signature in database
4 changed files with 74 additions and 29 deletions

View file

@ -1,5 +1,5 @@
async function getJson(url) { async function getJson(url, options) {
const response = await fetch(url); const response = await fetch(url, options);
if (!response.ok) { if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`); throw new Error(`${response.status} ${response.statusText}`);
} }
@ -33,8 +33,8 @@ export async function fetchEntryDetail(archiveId, entryUid) {
// Summaries are generated on demand, never at capture time. GET is safe for // Summaries are generated on demand, never at capture time. GET is safe for
// public sessions (the server applies the same visibility gate as entry detail). // public sessions (the server applies the same visibility gate as entry detail).
export async function fetchEntrySummary(archiveId, entryUid) { export async function fetchEntrySummary(archiveId, entryUid, { signal } = {}) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/summary`); return getJson(`/api/archives/${archiveId}/entries/${entryUid}/summary`, { signal });
} }
// Kicks off generation. Resolves to either an existing completed summary (200) // Kicks off generation. Resolves to either an existing completed summary (200)
@ -42,13 +42,14 @@ export async function fetchEntrySummary(archiveId, entryUid) {
// caller polls fetchEntrySummary either way. // caller polls fetchEntrySummary either way.
// The server returns 400 with the exact missing env var name when a provider is // The server returns 400 with the exact missing env var name when a provider is
// unconfigured, so its body is surfaced verbatim rather than replaced. // unconfigured, so its body is surfaced verbatim rather than replaced.
export async function requestEntrySummary(archiveId, entryUid, { provider, force = false, includeImages = false } = {}) { export async function requestEntrySummary(archiveId, entryUid, { provider, force = false, includeImages = false, signal } = {}) {
const resp = await fetch( const resp = await fetch(
`/api/archives/${archiveId}/entries/${entryUid}/summary`, `/api/archives/${archiveId}/entries/${entryUid}/summary`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, force, include_images: includeImages }), body: JSON.stringify({ provider, force, include_images: includeImages }),
signal,
} }
); );
if (!resp.ok) { if (!resp.ok) {
@ -63,6 +64,15 @@ export async function requestEntrySummary(archiveId, entryUid, { provider, force
return resp.json(); return resp.json();
} }
// Text artifacts are served by the same entry-artifact endpoint as previews.
// Keep credentials explicit because this helper is also used by public/private
// archive views, and preserve the previous concise HTTP error contract.
export async function fetchArtifactText(src, { signal } = {}) {
const response = await fetch(src, { credentials: 'same-origin', signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
}
export async function fetchEntryChildren(archiveId, entryUid) { export async function fetchEntryChildren(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/children`); return getJson(`/api/archives/${archiveId}/entries/${entryUid}/children`);
} }

View file

@ -681,8 +681,10 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
files.forEach(file => { files.forEach(file => {
const newItem = makeFileItem(file.name) const newItem = makeFileItem(file.name)
setItems(prev => { setItems(prev => {
// Replace a sole empty URL row with the file item; otherwise append // Only a normal URL row can be replaced. Text drafts deliberately use
if (prev.length === 1 && prev[0].kind !== 'file' && !prev[0].locator.trim()) { // an empty compatibility locator, but their title/body must survive a
// file attachment and remain independently archivable.
if (prev.length === 1 && !prev[0].kind && !prev[0].locator.trim()) {
return [newItem] return [newItem]
} }
return [...prev, newItem] return [...prev, newItem]

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useLayoutEffect, useRef } from 'react'
import { fetchEntryTags, assignTag, removeTag, listEntryCollections, listCollections, addEntryToCollection, updateEntryTitle, deleteEntry, rearchiveEntry, pollCaptureJob, fetchEntrySummary, requestEntrySummary } from '../api' import { fetchEntryTags, assignTag, removeTag, listEntryCollections, listCollections, addEntryToCollection, updateEntryTitle, deleteEntry, rearchiveEntry, pollCaptureJob, fetchEntrySummary, requestEntrySummary } from '../api'
import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils' import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
@ -74,6 +74,9 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
}) })
const [includeSummaryImages, setIncludeSummaryImages] = useState(false) const [includeSummaryImages, setIncludeSummaryImages] = useState(false)
const summaryPollRef = useRef(null) const summaryPollRef = useRef(null)
const summaryPollAbortRef = useRef(null)
const summaryGenerateAbortRef = useRef(null)
const summarySelectionRef = useRef(null)
// ── Bulk-panel state ──────────────────────────────────────────────────── // ── Bulk-panel state ────────────────────────────────────────────────────
const isBulk = selectedUids?.size >= 2 const isBulk = selectedUids?.size >= 2
@ -127,14 +130,22 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
// Seed the summary from the entry detail payload and stop any poll left over // Seed the summary from the entry detail payload and stop any poll left over
// from the previously selected entry. // from the previously selected entry.
useEffect(() => { useLayoutEffect(() => {
const selectionKey = archiveId && detail?.summary?.entry_uid
? `${archiveId}:${detail.summary.entry_uid}`
: null
summarySelectionRef.current = selectionKey
clearInterval(summaryPollRef.current) clearInterval(summaryPollRef.current)
summaryPollRef.current = null summaryPollRef.current = null
summaryPollAbortRef.current?.abort()
summaryPollAbortRef.current = null
summaryGenerateAbortRef.current?.abort()
summaryGenerateAbortRef.current = null
setSummary(detail?.latest_summary ?? null) setSummary(detail?.latest_summary ?? null)
setSummaryError('') setSummaryError('')
setSummaryBusy(false) setSummaryBusy(false)
setIncludeSummaryImages(false) setIncludeSummaryImages(false)
}, [detail?.summary?.entry_uid]) }, [archiveId, detail?.summary?.entry_uid])
// Poll only while the latest summary is non-terminal. Anchoring the effect on // Poll only while the latest summary is non-terminal. Anchoring the effect on
// the status (rather than starting a timer inside the click handler) means a // the status (rather than starting a timer inside the click handler) means a
@ -146,40 +157,60 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
if (summaryStatus !== 'pending' && summaryStatus !== 'running') return if (summaryStatus !== 'pending' && summaryStatus !== 'running') return
if (!archiveId || !detail?.summary?.entry_uid) return if (!archiveId || !detail?.summary?.entry_uid) return
const entryUid = detail.summary.entry_uid const entryUid = detail.summary.entry_uid
summaryPollRef.current = setInterval(async () => { const selectionKey = `${archiveId}:${entryUid}`
const controller = new AbortController()
summaryPollAbortRef.current = controller
const poll = async () => {
try { try {
const res = await fetchEntrySummary(archiveId, entryUid) const res = await fetchEntrySummary(archiveId, entryUid, { signal: controller.signal })
if (controller.signal.aborted || summarySelectionRef.current !== selectionKey) return
setSummary(res.summary ?? null) setSummary(res.summary ?? null)
const st = res.summary?.status const st = res.summary?.status
if (st !== 'pending' && st !== 'running') { if (st !== 'pending' && st !== 'running') {
clearInterval(summaryPollRef.current) clearInterval(intervalId)
summaryPollRef.current = null if (summaryPollRef.current === intervalId) summaryPollRef.current = null
setSummaryBusy(false) setSummaryBusy(false)
if (st === 'completed') onDetailRefresh?.() if (st === 'completed') onDetailRefresh?.()
} }
} catch { } catch (e) {
if (controller.signal.aborted || summarySelectionRef.current !== selectionKey) return
// A transient poll failure is not worth tearing the section down; the // A transient poll failure is not worth tearing the section down; the
// next tick retries, and a real failure lands as status === 'failed'. // next tick retries, and a real failure lands as status === 'failed'.
} }
}, SUMMARY_POLL_MS) }
const intervalId = setInterval(poll, SUMMARY_POLL_MS)
summaryPollRef.current = intervalId
return () => { return () => {
clearInterval(summaryPollRef.current) clearInterval(intervalId)
summaryPollRef.current = null if (summaryPollRef.current === intervalId) summaryPollRef.current = null
controller.abort()
if (summaryPollAbortRef.current === controller) summaryPollAbortRef.current = null
} }
}, [summaryStatus, archiveId, detail?.summary?.entry_uid]) }, [summaryStatus, archiveId, detail?.summary?.entry_uid])
useEffect(() => () => clearInterval(summaryPollRef.current), []) useEffect(() => () => {
clearInterval(summaryPollRef.current)
summaryPollAbortRef.current?.abort()
summaryGenerateAbortRef.current?.abort()
}, [])
async function handleGenerateSummary(force = false) { async function handleGenerateSummary(force = false) {
if (!archiveId || !detail?.summary?.entry_uid || summaryBusy) return if (!archiveId || !detail?.summary?.entry_uid || summaryBusy) return
const entryUid = detail.summary.entry_uid
const selectionKey = `${archiveId}:${entryUid}`
const controller = new AbortController()
summaryGenerateAbortRef.current?.abort()
summaryGenerateAbortRef.current = controller
setSummaryBusy(true) setSummaryBusy(true)
setSummaryError('') setSummaryError('')
try { try {
const res = await requestEntrySummary(archiveId, detail.summary.entry_uid, { const res = await requestEntrySummary(archiveId, entryUid, {
provider: summaryProvider, provider: summaryProvider,
force, force,
includeImages: includeSummaryImages, includeImages: includeSummaryImages,
signal: controller.signal,
}) })
if (controller.signal.aborted || summarySelectionRef.current !== selectionKey) return
if (res.status === 'completed') { if (res.status === 'completed') {
// 200 cache hit: the response *is* the row, no polling needed. // 200 cache hit: the response *is* the row, no polling needed.
setSummary(res) setSummary(res)
@ -191,8 +222,11 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
setSummary({ ...(res ?? {}), status: 'pending' }) setSummary({ ...(res ?? {}), status: 'pending' })
} }
} catch (e) { } catch (e) {
if (controller.signal.aborted || summarySelectionRef.current !== selectionKey) return
setSummaryError(e.message || 'Summary request failed') setSummaryError(e.message || 'Summary request failed')
setSummaryBusy(false) setSummaryBusy(false)
} finally {
if (summaryGenerateAbortRef.current === controller) summaryGenerateAbortRef.current = null
} }
} }
@ -402,7 +436,7 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
] : [] ] : []
const AUDIO_EXTS = new Set(['mp3','ogg','m4a','opus','wav','flac','aac']) const AUDIO_EXTS = new Set(['mp3','ogg','m4a','opus','wav','flac','aac'])
const PREVIEW_EXTS = new Set(['mp4','webm','mov','mkv','avi','m4v','ogv','pdf','html','htm','jpg','jpeg','png','gif','webp','avif','svg','bmp']) const PREVIEW_EXTS = new Set(['mp4','webm','mov','mkv','avi','m4v','ogv','pdf','html','htm','md','markdown','txt','jpg','jpeg','png','gif','webp','avif','svg','bmp'])
const primaryMediaIdx = detail ? detail.artifacts.findIndex(a => a.artifact_role === 'primary_media') : -1 const primaryMediaIdx = detail ? detail.artifacts.findIndex(a => a.artifact_role === 'primary_media') : -1
const primaryMedia = primaryMediaIdx >= 0 ? detail.artifacts[primaryMediaIdx] : null const primaryMedia = primaryMediaIdx >= 0 ? detail.artifacts[primaryMediaIdx] : null
const pmExt = primaryMedia ? primaryMedia.relpath.split('.').pop().toLowerCase() : '' const pmExt = primaryMedia ? primaryMedia.relpath.split('.').pop().toLowerCase() : ''

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { fetchArtifactText } from '../api'
/** /**
* Renders the primary_media artifact of a text/document entry as plain text. * Renders the primary_media artifact of a text/document entry as plain text.
@ -13,17 +14,15 @@ export default function TextPreview({ src, mime, title }) {
const [error, setError] = useState(null) const [error, setError] = useState(null)
useEffect(() => { useEffect(() => {
let cancelled = false const controller = new AbortController()
setText(null) setText(null)
setError(null) setError(null)
fetch(src, { credentials: 'same-origin' }) fetchArtifactText(src, { signal: controller.signal })
.then(r => { .then(setText)
if (!r.ok) throw new Error(`HTTP ${r.status}`) .catch(e => {
return r.text() if (e.name !== 'AbortError') setError(e.message || String(e))
}) })
.then(t => { if (!cancelled) setText(t) }) return () => controller.abort()
.catch(e => { if (!cancelled) setError(e.message || String(e)) })
return () => { cancelled = true }
}, [src]) }, [src])
if (error) { if (error) {