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

Merge branch 'fix/sol-frontend-notes' into integration-all-three

This commit is contained in:
archivr-qa 2026-08-24 16:31:18 +02:00
commit 80f6b84af2
No known key found for this signature in database
4 changed files with 84 additions and 34 deletions

View file

@ -1,5 +1,5 @@
async function getJson(url) {
const response = await fetch(url);
async function getJson(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
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
// public sessions (the server applies the same visibility gate as entry detail).
export async function fetchEntrySummary(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/summary`);
export async function fetchEntrySummary(archiveId, entryUid, { signal } = {}) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/summary`, { signal });
}
// 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.
// 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.
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(
`/api/archives/${archiveId}/entries/${entryUid}/summary`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, force, include_images: includeImages }),
signal,
}
);
if (!resp.ok) {
@ -63,6 +64,15 @@ export async function requestEntrySummary(archiveId, entryUid, { provider, force
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) {
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 => {
const newItem = makeFileItem(file.name)
setItems(prev => {
// Replace a sole empty URL row with the file item; otherwise append
if (prev.length === 1 && prev[0].kind !== 'file' && !prev[0].locator.trim()) {
// Only a normal URL row can be replaced. Text drafts deliberately use
// 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 [...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 { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
@ -74,6 +74,15 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
})
const [includeSummaryImages, setIncludeSummaryImages] = useState(false)
const summaryPollRef = useRef(null)
const summaryPollAbortRef = useRef(null)
const summaryGenerateAbortRef = useRef(null)
const summarySelectionRef = useRef(null)
// Update before effects run from the list selection, not detail: detail can
// briefly describe the previously selected entry while its replacement loads.
const summarySelectionKey = archiveId && selectedEntry?.entry_uid
? `${archiveId}:${selectedEntry.entry_uid}`
: null
summarySelectionRef.current = summarySelectionKey
// ── Bulk-panel state ────────────────────────────────────────────────────
const isBulk = selectedUids?.size >= 2
@ -127,14 +136,19 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
// Seed the summary from the entry detail payload and stop any poll left over
// from the previously selected entry.
useEffect(() => {
useLayoutEffect(() => {
clearInterval(summaryPollRef.current)
summaryPollRef.current = null
setSummary(detail?.latest_summary ?? null)
summaryPollAbortRef.current?.abort()
summaryPollAbortRef.current = null
summaryGenerateAbortRef.current?.abort()
summaryGenerateAbortRef.current = null
const detailMatchesSelection = detail?.summary?.entry_uid === selectedEntry?.entry_uid
setSummary(detailMatchesSelection ? detail.latest_summary ?? null : null)
setSummaryError('')
setSummaryBusy(false)
setIncludeSummaryImages(false)
}, [detail?.summary?.entry_uid])
}, [archiveId, selectedEntry?.entry_uid, detail?.summary?.entry_uid])
// 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
@ -146,53 +160,78 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
if (summaryStatus !== 'pending' && summaryStatus !== 'running') return
if (!archiveId || !detail?.summary?.entry_uid) return
const entryUid = detail.summary.entry_uid
summaryPollRef.current = setInterval(async () => {
const selectionKey = `${archiveId}:${entryUid}`
if (summarySelectionKey !== selectionKey) return
const controller = new AbortController()
summaryPollAbortRef.current = controller
const poll = async () => {
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)
const st = res.summary?.status
if (st !== 'pending' && st !== 'running') {
clearInterval(summaryPollRef.current)
summaryPollRef.current = null
clearInterval(intervalId)
if (summaryPollRef.current === intervalId) summaryPollRef.current = null
setSummaryBusy(false)
if (st === 'completed') onDetailRefresh?.()
if (st === 'completed' && summarySelectionRef.current === selectionKey) onDetailRefresh?.()
}
} catch {
} catch (e) {
if (controller.signal.aborted || summarySelectionRef.current !== selectionKey) return
// A transient poll failure is not worth tearing the section down; the
// next tick retries, and a real failure lands as status === 'failed'.
}
}, SUMMARY_POLL_MS)
return () => {
clearInterval(summaryPollRef.current)
summaryPollRef.current = null
}
}, [summaryStatus, archiveId, detail?.summary?.entry_uid])
const intervalId = setInterval(poll, SUMMARY_POLL_MS)
summaryPollRef.current = intervalId
return () => {
clearInterval(intervalId)
if (summaryPollRef.current === intervalId) summaryPollRef.current = null
controller.abort()
if (summaryPollAbortRef.current === controller) summaryPollAbortRef.current = null
}
}, [summaryStatus, archiveId, selectedEntry?.entry_uid, detail?.summary?.entry_uid, summarySelectionKey])
useEffect(() => () => clearInterval(summaryPollRef.current), [])
useEffect(() => () => {
clearInterval(summaryPollRef.current)
summaryPollAbortRef.current?.abort()
summaryGenerateAbortRef.current?.abort()
}, [])
async function handleGenerateSummary(force = false) {
if (!archiveId || !detail?.summary?.entry_uid || summaryBusy) return
const entryUid = detail.summary.entry_uid
const selectionKey = `${archiveId}:${entryUid}`
if (summarySelectionRef.current !== selectionKey) return
const controller = new AbortController()
summaryGenerateAbortRef.current?.abort()
summaryGenerateAbortRef.current = controller
setSummaryBusy(true)
setSummaryError('')
try {
const res = await requestEntrySummary(archiveId, detail.summary.entry_uid, {
const res = await requestEntrySummary(archiveId, entryUid, {
provider: summaryProvider,
force,
includeImages: includeSummaryImages,
signal: controller.signal,
})
if (controller.signal.aborted || summarySelectionRef.current !== selectionKey) return
if (res.status === 'completed') {
// 200 cache hit: the response *is* the row, no polling needed.
setSummary(res)
setSummaryBusy(false)
onDetailRefresh?.()
if (summarySelectionRef.current === selectionKey) onDetailRefresh?.()
} else {
// 202: seed a local pending row so the poll effect starts immediately
// rather than waiting a tick for the first GET.
setSummary({ ...(res ?? {}), status: 'pending' })
}
} catch (e) {
if (controller.signal.aborted || summarySelectionRef.current !== selectionKey) return
setSummaryError(e.message || 'Summary request failed')
setSummaryBusy(false)
} finally {
if (summaryGenerateAbortRef.current === controller) summaryGenerateAbortRef.current = null
}
}
@ -402,7 +441,7 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
] : []
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 primaryMedia = primaryMediaIdx >= 0 ? detail.artifacts[primaryMediaIdx] : null
const pmExt = primaryMedia ? primaryMedia.relpath.split('.').pop().toLowerCase() : ''

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { fetchArtifactText } from '../api'
/**
* 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)
useEffect(() => {
let cancelled = false
const controller = new AbortController()
setText(null)
setError(null)
fetch(src, { credentials: 'same-origin' })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.text()
fetchArtifactText(src, { signal: controller.signal })
.then(setText)
.catch(e => {
if (e.name !== 'AbortError') setError(e.message || String(e))
})
.then(t => { if (!cancelled) setText(t) })
.catch(e => { if (!cancelled) setError(e.message || String(e)) })
return () => { cancelled = true }
return () => controller.abort()
}, [src])
if (error) {