mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat(tweets): add re-archive button and fix thread-tweet orphan cleanup (#23)
Orphan cleanup bug: archiving x🧵A downloaded D/C/B/A JSONs and
media, but only registered artifacts for A. D/C/B files had no
entry_artifacts rows and were deleted as orphans.
Fix (staged scraper output, precise touched set):
- tweets::archive() stages all scraper output in temp/{ts}/tweet_stage/,
validates, then renames JSONs to raw_tweets/. Return type changed from
Result<bool> to Result<Vec<String>> (store-relative relpaths of every
produced tweet JSON, i.e. the exact touched set).
- tweets::rearchive() (new): same staged approach but always runs the
scraper. On scraper failure (tweet deleted/private), errors before
touching raw_tweets/ so existing data is preserved.
- register_tweet_artifacts() (new private helper in capture.rs): registers
every JSON in the touched set as a raw_tweet_json artifact, parses each
for media blobs, registers those too. JSON read failure is a hard error
with context, not a silent skip.
- record_tweet_entry() now accepts tweet_json_relpaths: &[String] and
delegates artifact registration to register_tweet_artifacts().
- perform_capture() passes the returned vec from tweets::archive().
Re-archive feature:
- capture::perform_rearchive(): looks up entry by uid, validates
tweet/tweet_thread, runs tweets::rearchive(), atomically swaps
entry_artifacts in a DB transaction. archived_at, title, tags,
collections are untouched.
- database: add get_entry_for_rearchive() and delete_entry_artifacts().
- POST /api/archives/:id/entries/:uid/rearchive: requires ROLE_USER,
creates capture job, returns 202 + job_uid, runs perform_rearchive in
spawn_blocking.
- Frontend: re-archive button in ContextRail for tweet/tweet_thread
entries; polls job at 500ms; refreshes entry detail on success; shows
error text on failure. Poll interval cleared before early-return on
entry deselect to prevent stale updates.
This commit is contained in:
parent
03390362c5
commit
2779afee2d
12 changed files with 528 additions and 174 deletions
|
|
@ -371,6 +371,18 @@ export async function deleteOrphanBlobs(archiveId) {
|
|||
return res.json()
|
||||
}
|
||||
|
||||
// ── Re-archive ────────────────────────────────────────────────────────────────
|
||||
export async function rearchiveEntry(archiveId, entryUid) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/entries/${entryUid}/rearchive`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.message || `rearchive failed: ${res.status}`)
|
||||
}
|
||||
return res.json() // { job_uid, status: 'pending' }
|
||||
}
|
||||
|
||||
// ── Cookie rules ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listCookieRules() {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle, deleteEntry } from '../api'
|
||||
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle, deleteEntry, rearchiveEntry, pollCaptureJob } from '../api'
|
||||
import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
|
||||
|
||||
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
|
||||
|
|
@ -21,8 +21,14 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
|||
const titleCancelRef = useRef(false)
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
const [titleDraft, setTitleDraft] = useState('')
|
||||
const [rearchiveState, setRearchiveState] = useState('idle') // 'idle' | 'running' | 'done' | 'error'
|
||||
const [rearchiveError, setRearchiveError] = useState('')
|
||||
const rearchivePollRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (rearchivePollRef.current) { clearInterval(rearchivePollRef.current); rearchivePollRef.current = null }
|
||||
setRearchiveState('idle')
|
||||
setRearchiveError('')
|
||||
if (!selectedEntry || !archiveId) {
|
||||
setDetail(null)
|
||||
setTags([])
|
||||
|
|
@ -47,6 +53,12 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
|||
}).catch(() => {})
|
||||
}, [selectedEntry, archiveId])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearInterval(rearchivePollRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleTitleSave() {
|
||||
const newTitle = titleDraft.trim() || null
|
||||
try {
|
||||
|
|
@ -97,6 +109,46 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
|||
}
|
||||
}
|
||||
|
||||
async function handleRearchive() {
|
||||
if (!selectedEntry || !archiveId || rearchiveState === 'running') return
|
||||
setRearchiveState('running')
|
||||
setRearchiveError('')
|
||||
try {
|
||||
const { job_uid } = await rearchiveEntry(archiveId, selectedEntry.entry_uid)
|
||||
// Poll for job completion at 500ms — same interval as CaptureDialog
|
||||
rearchivePollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const job = await pollCaptureJob(archiveId, job_uid)
|
||||
if (job.status === 'completed') {
|
||||
clearInterval(rearchivePollRef.current)
|
||||
rearchivePollRef.current = null
|
||||
setRearchiveState('done')
|
||||
// Refresh entry detail so artifact list updates
|
||||
const [det, tgs] = await Promise.all([
|
||||
fetchEntryDetail(archiveId, selectedEntry.entry_uid),
|
||||
fetchEntryTags(archiveId, selectedEntry.entry_uid),
|
||||
])
|
||||
setDetail(det)
|
||||
setTags(tgs)
|
||||
} else if (job.status === 'failed') {
|
||||
clearInterval(rearchivePollRef.current)
|
||||
rearchivePollRef.current = null
|
||||
setRearchiveState('error')
|
||||
setRearchiveError(job.error_text || 'Re-archive failed.')
|
||||
}
|
||||
} catch {
|
||||
clearInterval(rearchivePollRef.current)
|
||||
rearchivePollRef.current = null
|
||||
setRearchiveState('error')
|
||||
setRearchiveError('Network error while polling.')
|
||||
}
|
||||
}, 500)
|
||||
} catch (e) {
|
||||
setRearchiveState('error')
|
||||
setRearchiveError(e.message || 'Failed to start re-archive.')
|
||||
}
|
||||
}
|
||||
|
||||
const metaRows = detail ? [
|
||||
['Added', formatTimestamp(detail.summary.archived_at)],
|
||||
['Source', detail.summary.source_kind],
|
||||
|
|
@ -244,6 +296,25 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
|||
</div>
|
||||
)}
|
||||
|
||||
{detail && (detail.summary.entity_kind === 'tweet' || detail.summary.entity_kind === 'tweet_thread') && (
|
||||
<div className="rail-section">
|
||||
<div className="rail-section-heading">Actions</div>
|
||||
<button
|
||||
className="rail-rearchive-btn"
|
||||
onClick={handleRearchive}
|
||||
disabled={rearchiveState === 'running'}
|
||||
>
|
||||
{rearchiveState === 'running' ? 'Re-archiving\u2026' : 'Re-archive'}
|
||||
</button>
|
||||
{rearchiveState === 'done' && (
|
||||
<p className="form-msg form-msg--ok" style={{ marginTop: '6px' }}>Re-archived successfully.</p>
|
||||
)}
|
||||
{rearchiveState === 'error' && (
|
||||
<p className="form-msg form-msg--err" style={{ marginTop: '6px' }}>{rearchiveError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rail-delete-zone">
|
||||
<button className="rail-delete-btn" onClick={handleDeleteEntry}>
|
||||
Delete entry
|
||||
|
|
|
|||
|
|
@ -552,6 +552,24 @@ select {
|
|||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.rail-rearchive-btn {
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
background: var(--surface-2, #2a2a2a);
|
||||
color: var(--text, #e0e0e0);
|
||||
border: 1px solid var(--border, #444);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.rail-rearchive-btn:hover:not(:disabled) {
|
||||
background: var(--surface-3, #333);
|
||||
}
|
||||
.rail-rearchive-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Capture dialog ──────────────────────────────────────────────────────── */
|
||||
.capture-dialog {
|
||||
border: 1px solid var(--line);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue