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:
parent
492b1a3168
commit
222d237044
4 changed files with 74 additions and 29 deletions
|
|
@ -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`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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() : ''
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue