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:
parent
2e8820a0da
commit
03390362c5
10 changed files with 195 additions and 89 deletions
|
|
@ -900,14 +900,12 @@ pub fn perform_capture(
|
|||
Ok(downloader::http::UrlKind::Html) => source = Source::WebPage,
|
||||
Ok(downloader::http::UrlKind::File) => {}
|
||||
Err(e) => {
|
||||
// Record a failed item using the pre-probe source_kind so
|
||||
// failed_count increments and the item carries error_text.
|
||||
let (probe_sk, probe_ek, _) = source_metadata(Source::Url);
|
||||
let item = database::create_archive_run_item(
|
||||
&conn, run.id, None, 0, locator, None, probe_sk, probe_ek,
|
||||
)?;
|
||||
let msg = format!("Failed to probe URL: {e}");
|
||||
return Err(fail_run(&conn, &run, &item, &msg));
|
||||
// Probe failed (e.g. Cloudflare JS challenge, 403, network
|
||||
// issue). Fall back to treating the URL as an HTML page so
|
||||
// that the SingleFile/Chromium path can try — a real browser
|
||||
// can pass bot challenges that a plain HTTP client cannot.
|
||||
eprintln!("warn: probe failed for {locator}, assuming HTML: {e}");
|
||||
source = Source::WebPage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ pub enum UrlKind {
|
|||
pub fn probe_url_kind(url: &str, cookies: &HashMap<String, String>) -> Result<UrlKind> {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.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()
|
||||
.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>)> {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.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()
|
||||
.context("failed to build HTTP client")?;
|
||||
|
||||
|
|
|
|||
45
crates/archivr-server/static/assets/index-BbGz9qlG.js
Normal file
45
crates/archivr-server/static/assets/index-BbGz9qlG.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Archivr</title>
|
||||
<script type="module" crossorigin src="/assets/index-D6iX-c2T.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C0BIEdow.css">
|
||||
<script type="module" crossorigin src="/assets/index-BbGz9qlG.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D0Nzcia0.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -241,10 +241,12 @@ export default function App() {
|
|||
])
|
||||
}, [archiveId, searchQuery, tagFilter, loadEntries])
|
||||
|
||||
const handleToast = useCallback((text, locator, type = 'error') => {
|
||||
if (type === 'warning' && ublockWarningIgnored) return
|
||||
const handleToast = useCallback((text, locator, type = 'error', headline = null) => {
|
||||
// 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
|
||||
setToasts(prev => [...prev, { id, text, locator, type }])
|
||||
setToasts(prev => [...prev, { id, text, locator, type, headline }])
|
||||
}, [ublockWarningIgnored])
|
||||
|
||||
const handleDismissToast = useCallback((id) => {
|
||||
|
|
@ -254,7 +256,7 @@ export default function App() {
|
|||
const handleIgnoreUblock = useCallback(() => {
|
||||
sessionStorage.setItem('ublockWarningIgnored', '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>;
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
const pollIntervals = useRef(new Map())
|
||||
// itemId → debounce timeoutId for probe calls
|
||||
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
|
||||
const archiveIdRef = useRef(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
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
|
|
@ -202,7 +204,6 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
if (updated.status === 'completed') {
|
||||
clearInterval(pollIntervals.current.get(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))
|
||||
setTimeout(() => {
|
||||
setItems(prev => {
|
||||
|
|
@ -211,13 +212,25 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
})
|
||||
}, 1400)
|
||||
onCapturedRef.current()
|
||||
// Warn if uBlock was requested but the extension wasn't available
|
||||
try {
|
||||
const notes = updated.notes_json ? JSON.parse(updated.notes_json) : null
|
||||
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') {
|
||||
clearInterval(pollIntervals.current.get(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
|
||||
))
|
||||
onToastRef.current(errText, locator)
|
||||
settleBatch(batchId, 'failed', locator)
|
||||
}
|
||||
// 'pending' / 'running': keep polling
|
||||
} catch (e) {
|
||||
|
|
@ -236,14 +250,51 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
it.id === itemId ? { ...it, status: 'failed', error: msg } : it
|
||||
))
|
||||
onToastRef.current(msg, locator)
|
||||
settleBatch(batchId, 'failed', locator)
|
||||
}
|
||||
}, 500)
|
||||
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
|
||||
const aid = archiveId // capture at submit time
|
||||
const aid = archiveId
|
||||
const loc = item.locator.trim()
|
||||
const qual = item.quality || 'best'
|
||||
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 =>
|
||||
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) {
|
||||
const msg = e.message || 'Submission failed.'
|
||||
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'failed', error: msg } : it))
|
||||
onToastRef.current(msg, loc)
|
||||
settleBatch(batchId, 'failed', loc)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleArchive() {
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const isWarning = toast.type === 'warning'
|
||||
const isSuccess = toast.type === 'success'
|
||||
|
||||
// Auto-dismiss after TTL; paused while detail is expanded
|
||||
useEffect(() => {
|
||||
|
|
@ -24,9 +42,24 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
|
|||
return () => clearTimeout(timer)
|
||||
}, [expanded, toast.id, onDismiss])
|
||||
|
||||
const short = toast.locator
|
||||
? (toast.locator.length > 48 ? toast.locator.slice(0, 45) + '\u2026' : toast.locator)
|
||||
: null
|
||||
const short = shortLocator(toast.locator)
|
||||
|
||||
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) {
|
||||
return (
|
||||
|
|
@ -34,10 +67,11 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
|
|||
<div className="toast-top">
|
||||
<span className="toast-icon" aria-hidden="true">⚠</span>
|
||||
<div className="toast-body">
|
||||
<span className="toast-headline">Ad-blocking unavailable</span>
|
||||
{short && <span className="toast-locator">{short}</span>}
|
||||
<span className="toast-headline">{toast.headline || 'Archived with warnings'}</span>
|
||||
{short && <span className="toast-locator" title={toast.locator}>{short}</span>}
|
||||
</div>
|
||||
<div className="toast-btns">
|
||||
{toast.text && (
|
||||
<button
|
||||
type="button"
|
||||
className="toast-view-btn"
|
||||
|
|
@ -46,6 +80,8 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
|
|||
>
|
||||
{expanded ? 'Hide' : 'Details'}
|
||||
</button>
|
||||
)}
|
||||
{toast.locator && (
|
||||
<button
|
||||
type="button"
|
||||
className="toast-view-btn toast-ignore-btn"
|
||||
|
|
@ -53,6 +89,7 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
|
|||
>
|
||||
Ignore
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="toast-dismiss"
|
||||
|
|
@ -65,7 +102,7 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
|
|||
</div>
|
||||
{expanded && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -77,8 +114,8 @@ function Toast({ toast, onDismiss, onIgnoreUblock }) {
|
|||
<div className="toast-top">
|
||||
<span className="toast-icon" aria-hidden="true">✕</span>
|
||||
<div className="toast-body">
|
||||
<span className="toast-headline">Capture failed</span>
|
||||
{short && <span className="toast-locator">{short}</span>}
|
||||
<span className="toast-headline">{toast.headline || 'Capture failed'}</span>
|
||||
{short && <span className="toast-locator" title={toast.locator}>{short}</span>}
|
||||
</div>
|
||||
<div className="toast-btns">
|
||||
{toast.text && (
|
||||
|
|
|
|||
|
|
@ -869,6 +869,7 @@ select {
|
|||
}
|
||||
.toast--error { border-left: 3px solid var(--accent); }
|
||||
.toast--warning { border-left: 3px solid #e8a000; }
|
||||
.toast--success { border-left: 3px solid #22a855; }
|
||||
|
||||
.toast-top {
|
||||
display: flex;
|
||||
|
|
@ -883,6 +884,8 @@ select {
|
|||
color: var(--accent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.toast--success .toast-icon { color: #22a855; }
|
||||
.toast--warning .toast-icon { color: #e8a000; }
|
||||
.toast-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue