1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

capture: close dialog on Archive; rich per-URL and batch toast notifications (#22)

* http: realistic UA; fall back to WebPage on probe failure

Replace bare 'archivr/0.1' user agent with a full Chrome 131 UA string
(archivr/0.1 token retained at the end) in both probe_url_kind and download.

More importantly, stop hard-failing when probe_url_kind returns an error.
Sites behind Cloudflare's managed JS challenge (e.g. Medium) return 403
before any header tuning can help — a plain HTTP client cannot pass the
challenge. Instead, log a warning and fall back to Source::WebPage so that
the SingleFile/Chromium path gets a chance; a real browser can solve the
challenge transparently.

* capture: close dialog on Archive; rich per-URL and batch toast notifications

UX changes:
- Pressing Archive closes the capture dialog immediately; jobs continue
  polling in the background (component stays mounted).
- Probe failures (e.g. Cloudflare JS challenge, HTTP 403) now fall back to
  Source::WebPage so SingleFile/Chromium can attempt the capture instead of
  hard-failing at the probe stage.

Toast notifications:
- Single URL: green 'Archived' on success, amber 'Archived with warnings'
  (with expandable detail) when uBlock/cookie-ext was skipped, red 'Capture
  failed' with expandable error text on failure. Per-item warning/error toasts
  include the locator so the user knows which URL was affected.
- Multi-URL batch: per-item failure and warning toasts still fire with
  locators; per-item success toasts are suppressed. Once all jobs settle a
  single summary toast fires: 'N archived', 'N archived (M with warnings)',
  'N archived (M with warnings), F failed', or 'N failed'. The summary Detail
  section lists the exact URLs that failed or warned, so the user retains that
  information after per-item toasts auto-dismiss.
- Batch summary color: green = all clean; amber = any warnings or failures
  present; red = all failed.
- handleIgnoreUblock now only removes per-item warning toasts (those with a
  locator) and persists the ignore flag; batch summary warnings (locator=null)
  are not swept.

ToastStack improvements:
- All three branches (success/warning/error) support a toast.headline field
  so batch summaries can set their own copy.
- Warning branch: Details button conditional on toast.text; Ignore button
  conditional on toast.locator (uBlock-specific, not shown on batch summaries).
- Locator display uses hostname/…/last-segment for URLs (preserves domain for
  context, tail for identity) and tail-truncation for shorthands; full locator
  in title attribute for hover.
- Icon colors: green for success (✓), amber for warning (⚠), red for error (✕).
This commit is contained in:
TheGeneralist 2026-07-11 15:29:10 +02:00 committed by GitHub
parent 2e8820a0da
commit 03390362c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 195 additions and 89 deletions

View file

@ -900,14 +900,12 @@ pub fn perform_capture(
Ok(downloader::http::UrlKind::Html) => source = Source::WebPage, Ok(downloader::http::UrlKind::Html) => source = Source::WebPage,
Ok(downloader::http::UrlKind::File) => {} Ok(downloader::http::UrlKind::File) => {}
Err(e) => { Err(e) => {
// Record a failed item using the pre-probe source_kind so // Probe failed (e.g. Cloudflare JS challenge, 403, network
// failed_count increments and the item carries error_text. // issue). Fall back to treating the URL as an HTML page so
let (probe_sk, probe_ek, _) = source_metadata(Source::Url); // that the SingleFile/Chromium path can try — a real browser
let item = database::create_archive_run_item( // can pass bot challenges that a plain HTTP client cannot.
&conn, run.id, None, 0, locator, None, probe_sk, probe_ek, eprintln!("warn: probe failed for {locator}, assuming HTML: {e}");
)?; source = Source::WebPage;
let msg = format!("Failed to probe URL: {e}");
return Err(fail_run(&conn, &run, &item, &msg));
} }
} }
} }

View file

@ -20,7 +20,7 @@ pub enum UrlKind {
pub fn probe_url_kind(url: &str, cookies: &HashMap<String, String>) -> Result<UrlKind> { pub fn probe_url_kind(url: &str, cookies: &HashMap<String, String>) -> Result<UrlKind> {
let client = reqwest::blocking::Client::builder() let client = reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::limited(10)) .redirect(reqwest::redirect::Policy::limited(10))
.user_agent("archivr/0.1") .user_agent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 archivr/0.1")
.build() .build()
.context("failed to build HTTP client")?; .context("failed to build HTTP client")?;
@ -86,7 +86,7 @@ pub fn probe_url_kind(url: &str, cookies: &HashMap<String, String>) -> Result<Ur
pub fn download(url: &str, store_path: &Path, timestamp: &str, cookies: &HashMap<String, String>) -> Result<(String, String, Option<String>)> { pub fn download(url: &str, store_path: &Path, timestamp: &str, cookies: &HashMap<String, String>) -> Result<(String, String, Option<String>)> {
let client = reqwest::blocking::Client::builder() let client = reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::limited(10)) .redirect(reqwest::redirect::Policy::limited(10))
.user_agent("archivr/0.1") .user_agent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 archivr/0.1")
.build() .build()
.context("failed to build HTTP client")?; .context("failed to build HTTP client")?;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title> <title>Archivr</title>
<script type="module" crossorigin src="/assets/index-D6iX-c2T.js"></script> <script type="module" crossorigin src="/assets/index-BbGz9qlG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C0BIEdow.css"> <link rel="stylesheet" crossorigin href="/assets/index-D0Nzcia0.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -241,10 +241,12 @@ export default function App() {
]) ])
}, [archiveId, searchQuery, tagFilter, loadEntries]) }, [archiveId, searchQuery, tagFilter, loadEntries])
const handleToast = useCallback((text, locator, type = 'error') => { const handleToast = useCallback((text, locator, type = 'error', headline = null) => {
if (type === 'warning' && ublockWarningIgnored) return // Only suppress per-item ublock/cookie warnings (those carry a locator).
// Batch summary warnings (locator = null) must always show.
if (type === 'warning' && ublockWarningIgnored && locator) return
const id = ++toastIdRef.current const id = ++toastIdRef.current
setToasts(prev => [...prev, { id, text, locator, type }]) setToasts(prev => [...prev, { id, text, locator, type, headline }])
}, [ublockWarningIgnored]) }, [ublockWarningIgnored])
const handleDismissToast = useCallback((id) => { const handleDismissToast = useCallback((id) => {
@ -254,7 +256,7 @@ export default function App() {
const handleIgnoreUblock = useCallback(() => { const handleIgnoreUblock = useCallback(() => {
sessionStorage.setItem('ublockWarningIgnored', 'true') sessionStorage.setItem('ublockWarningIgnored', 'true')
setUblockWarningIgnored(true) setUblockWarningIgnored(true)
setToasts(prev => prev.filter(t => t.type !== 'warning')) setToasts(prev => prev.filter(t => !(t.type === 'warning' && t.locator)))
}, []) }, [])
if (authState === 'loading') return <div className="auth-loading">Loading\u2026</div>; if (authState === 'loading') return <div className="auth-loading">Loading\u2026</div>;

View file

@ -85,6 +85,8 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
const pollIntervals = useRef(new Map()) const pollIntervals = useRef(new Map())
// itemId debounce timeoutId for probe calls // itemId debounce timeoutId for probe calls
const probeTimers = useRef(new Map()) const probeTimers = useRef(new Map())
// batchId { total, archived, warnings, failed }; only populated for multi-URL submits
const batchRef = useRef(new Map())
// stable ref so probe callbacks always see the current archiveId // stable ref so probe callbacks always see the current archiveId
const archiveIdRef = useRef(archiveId) const archiveIdRef = useRef(archiveId)
useEffect(() => { archiveIdRef.current = archiveId }, [archiveId]) useEffect(() => { archiveIdRef.current = archiveId }, [archiveId])
@ -194,7 +196,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
} }
}, []) }, [])
function startPolling(itemId, jobUid, locator, aid) { function startPolling(itemId, jobUid, locator, aid, batchId = null) {
if (pollIntervals.current.has(jobUid)) return // already polling if (pollIntervals.current.has(jobUid)) return // already polling
const intervalId = setInterval(async () => { const intervalId = setInterval(async () => {
try { try {
@ -202,7 +204,6 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
if (updated.status === 'completed') { if (updated.status === 'completed') {
clearInterval(pollIntervals.current.get(jobUid)) clearInterval(pollIntervals.current.get(jobUid))
pollIntervals.current.delete(jobUid) pollIntervals.current.delete(jobUid)
// Show briefly then remove the row; if last row add a fresh one
setItems(prev => prev.map(it => it.id === itemId ? { ...it, status: 'completed' } : it)) setItems(prev => prev.map(it => it.id === itemId ? { ...it, status: 'completed' } : it))
setTimeout(() => { setTimeout(() => {
setItems(prev => { setItems(prev => {
@ -211,13 +212,25 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
}) })
}, 1400) }, 1400)
onCapturedRef.current() onCapturedRef.current()
// Warn if uBlock was requested but the extension wasn't available
try { try {
const notes = updated.notes_json ? JSON.parse(updated.notes_json) : null const notes = updated.notes_json ? JSON.parse(updated.notes_json) : null
if (notes?.ublock_skipped || notes?.cookie_ext_skipped) { if (notes?.ublock_skipped || notes?.cookie_ext_skipped) {
onToastRef.current(null, locator, 'warning') const both = notes.ublock_skipped && notes.cookie_ext_skipped
const msg = both
? 'Captured without ad-blocking or cookie-consent extension. Check ARCHIVR_UBLOCK_EXT and ARCHIVR_COOKIE_EXT in your server config.'
: notes.ublock_skipped
? 'Captured without ad-blocking. ARCHIVR_UBLOCK=true but ARCHIVR_UBLOCK_EXT is not set or the path is invalid.'
: 'Captured without cookie-consent extension. ARCHIVR_COOKIE_EXT is not set or the path is invalid.'
onToastRef.current(msg, locator, 'warning')
settleBatch(batchId, 'warning', locator)
} else {
if (!batchId) onToastRef.current(null, locator, 'success')
settleBatch(batchId, 'archived')
}
} catch {
if (!batchId) onToastRef.current(null, locator, 'success')
settleBatch(batchId, 'archived')
} }
} catch {}
} else if (updated.status === 'failed') { } else if (updated.status === 'failed') {
clearInterval(pollIntervals.current.get(jobUid)) clearInterval(pollIntervals.current.get(jobUid))
pollIntervals.current.delete(jobUid) pollIntervals.current.delete(jobUid)
@ -226,6 +239,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
it.id === itemId ? { ...it, status: 'failed', error: errText } : it it.id === itemId ? { ...it, status: 'failed', error: errText } : it
)) ))
onToastRef.current(errText, locator) onToastRef.current(errText, locator)
settleBatch(batchId, 'failed', locator)
} }
// 'pending' / 'running': keep polling // 'pending' / 'running': keep polling
} catch (e) { } catch (e) {
@ -236,14 +250,51 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
it.id === itemId ? { ...it, status: 'failed', error: msg } : it it.id === itemId ? { ...it, status: 'failed', error: msg } : it
)) ))
onToastRef.current(msg, locator) onToastRef.current(msg, locator)
settleBatch(batchId, 'failed', locator)
} }
}, 500) }, 500)
pollIntervals.current.set(jobUid, intervalId) pollIntervals.current.set(jobUid, intervalId)
} }
async function submitItem(item) { // Increments the batch counter for the given outcome and emits a summary
// toast once all jobs in the batch have settled.
// 'warning' counts as archived (succeeded with caveats); locator recorded for detail text.
function settleBatch(batchId, outcome, locator = null) {
if (!batchId) return
const batch = batchRef.current.get(batchId)
if (!batch) return
if (outcome === 'archived' || outcome === 'warning') {
batch.archived++
if (outcome === 'warning') { batch.warnings++; if (locator) batch.warningLocators.push(locator) }
} else {
batch.failed++
if (locator) batch.failedLocators.push(locator)
}
if (batch.archived + batch.failed < batch.total) return
// All settled build headline + detail text, then emit summary and clean up.
batchRef.current.delete(batchId)
const { archived, warnings, failed, failedLocators, warningLocators } = batch
let headline
if (archived === 0) {
headline = `${failed} failed`
} else {
const archivedStr = warnings > 0
? `${archived} archived (${warnings} with warnings)`
: `${archived} archived`
headline = failed > 0 ? `${archivedStr}, ${failed} failed` : archivedStr
}
const type = archived === 0 ? 'error' : (failed > 0 || warnings > 0) ? 'warning' : 'success'
const parts = []
if (failedLocators.length > 0) parts.push(`Failed:\n${failedLocators.map(l => ` ${l}`).join('\n')}`)
if (warningLocators.length > 0) parts.push(`With warnings:\n${warningLocators.map(l => ` ${l}`).join('\n')}`)
const text = parts.length > 0 ? parts.join('\n') : null
onToastRef.current(text, null, type, headline)
}
async function submitItem(item, batchId = null) {
if (!item.locator.trim()) return if (!item.locator.trim()) return
const aid = archiveId // capture at submit time const aid = archiveId
const loc = item.locator.trim() const loc = item.locator.trim()
const qual = item.quality || 'best' const qual = item.quality || 'best'
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it)) setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it))
@ -253,17 +304,27 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
setItems(prev => prev.map(it => setItems(prev => prev.map(it =>
it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : it it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : it
)) ))
startPolling(item.id, job.job_uid, loc, aid) startPolling(item.id, job.job_uid, loc, aid, batchId)
} catch (e) { } catch (e) {
const msg = e.message || 'Submission failed.' const msg = e.message || 'Submission failed.'
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'failed', error: msg } : it)) setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'failed', error: msg } : it))
onToastRef.current(msg, loc) onToastRef.current(msg, loc)
settleBatch(batchId, 'failed', loc)
} }
} }
function handleArchive() { function handleArchive() {
const toSubmit = items.filter(it => it.status === 'idle' && it.locator.trim()) const toSubmit = items.filter(it => it.status === 'idle' && it.locator.trim())
toSubmit.forEach(it => submitItem(it)) if (toSubmit.length === 0) return
const batchId = toSubmit.length > 1
? (crypto.randomUUID?.() ?? `batch-${Date.now()}`)
: null
if (batchId) {
batchRef.current.set(batchId, { total: toSubmit.length, archived: 0, warnings: 0, failed: 0, failedLocators: [], warningLocators: [] })
}
toSubmit.forEach(it => submitItem(it, batchId))
dialogRef.current?.close()
} }
function addRow() { function addRow() {

View file

@ -13,9 +13,27 @@ export default function ToastStack({ toasts, onDismiss, onIgnoreUblock }) {
) )
} }
// For URLs: show hostname//tail (drops protocol + middle path, keeps domain + identifier).
// For shorthands (yt:, x:, etc.): keep the tail since it's the whole identifier.
// CSS text-overflow:ellipsis on .toast-locator handles any remaining overflow.
function shortLocator(locator) {
if (!locator) return null
if (locator.length <= 52) return locator
try {
const { hostname, pathname, search } = new URL(locator)
const segments = pathname.split('/').filter(Boolean)
const tail = (segments[segments.length - 1] ?? '') + search
const candidate = tail ? `${hostname}/\u2026/${tail}` : hostname
return candidate.length <= 56 ? candidate : candidate.slice(0, 53) + '\u2026'
} catch {
return '\u2026' + locator.slice(-51)
}
}
function Toast({ toast, onDismiss, onIgnoreUblock }) { function Toast({ toast, onDismiss, onIgnoreUblock }) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const isWarning = toast.type === 'warning' const isWarning = toast.type === 'warning'
const isSuccess = toast.type === 'success'
// Auto-dismiss after TTL; paused while detail is expanded // Auto-dismiss after TTL; paused while detail is expanded
useEffect(() => { useEffect(() => {
@ -24,9 +42,24 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [expanded, toast.id, onDismiss]) }, [expanded, toast.id, onDismiss])
const short = toast.locator const short = shortLocator(toast.locator)
? (toast.locator.length > 48 ? toast.locator.slice(0, 45) + '\u2026' : toast.locator)
: null if (isSuccess) {
return (
<div className="toast toast--success" role="alert" aria-atomic="true">
<div className="toast-top">
<span className="toast-icon" aria-hidden="true"></span>
<div className="toast-body">
<span className="toast-headline">{toast.headline || 'Archived'}</span>
{short && <span className="toast-locator" title={toast.locator}>{short}</span>}
</div>
<div className="toast-btns">
<button type="button" className="toast-dismiss" onClick={() => onDismiss(toast.id)} aria-label="Dismiss">×</button>
</div>
</div>
</div>
)
}
if (isWarning) { if (isWarning) {
return ( return (
@ -34,10 +67,11 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
<div className="toast-top"> <div className="toast-top">
<span className="toast-icon" aria-hidden="true"></span> <span className="toast-icon" aria-hidden="true"></span>
<div className="toast-body"> <div className="toast-body">
<span className="toast-headline">Ad-blocking unavailable</span> <span className="toast-headline">{toast.headline || 'Archived with warnings'}</span>
{short && <span className="toast-locator">{short}</span>} {short && <span className="toast-locator" title={toast.locator}>{short}</span>}
</div> </div>
<div className="toast-btns"> <div className="toast-btns">
{toast.text && (
<button <button
type="button" type="button"
className="toast-view-btn" className="toast-view-btn"
@ -46,6 +80,8 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
> >
{expanded ? 'Hide' : 'Details'} {expanded ? 'Hide' : 'Details'}
</button> </button>
)}
{toast.locator && (
<button <button
type="button" type="button"
className="toast-view-btn toast-ignore-btn" className="toast-view-btn toast-ignore-btn"
@ -53,6 +89,7 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
> >
Ignore Ignore
</button> </button>
)}
<button <button
type="button" type="button"
className="toast-dismiss" className="toast-dismiss"
@ -65,7 +102,7 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
</div> </div>
{expanded && ( {expanded && (
<p className="toast-warning-detail"> <p className="toast-warning-detail">
{toast.text || 'ARCHIVR_UBLOCK=true but ARCHIVR_UBLOCK_EXT is not set or the path is invalid. The page was captured without ad-blocking. Set ARCHIVR_UBLOCK_EXT to the unpacked uBlock Origin Lite extension directory to enable ad-blocking, or set ARCHIVR_UBLOCK=false to silence this warning.'} {toast.text || 'The page was captured but one or more browser extensions were unavailable (ad-blocking or cookie-consent). Check ARCHIVR_UBLOCK_EXT and ARCHIVR_COOKIE_EXT in your server config.'}
</p> </p>
)} )}
</div> </div>
@ -77,8 +114,8 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
<div className="toast-top"> <div className="toast-top">
<span className="toast-icon" aria-hidden="true"></span> <span className="toast-icon" aria-hidden="true"></span>
<div className="toast-body"> <div className="toast-body">
<span className="toast-headline">Capture failed</span> <span className="toast-headline">{toast.headline || 'Capture failed'}</span>
{short && <span className="toast-locator">{short}</span>} {short && <span className="toast-locator" title={toast.locator}>{short}</span>}
</div> </div>
<div className="toast-btns"> <div className="toast-btns">
{toast.text && ( {toast.text && (

View file

@ -869,6 +869,7 @@ select {
} }
.toast--error { border-left: 3px solid var(--accent); } .toast--error { border-left: 3px solid var(--accent); }
.toast--warning { border-left: 3px solid #e8a000; } .toast--warning { border-left: 3px solid #e8a000; }
.toast--success { border-left: 3px solid #22a855; }
.toast-top { .toast-top {
display: flex; display: flex;
@ -883,6 +884,8 @@ select {
color: var(--accent); color: var(--accent);
margin-top: 2px; margin-top: 2px;
} }
.toast--success .toast-icon { color: #22a855; }
.toast--warning .toast-icon { color: #e8a000; }
.toast-body { .toast-body {
flex: 1; flex: 1;
min-width: 0; min-width: 0;