diff --git a/src/__tests__/scan.test.js b/src/__tests__/scan.test.js index bd0bde6..ed483b6 100644 --- a/src/__tests__/scan.test.js +++ b/src/__tests__/scan.test.js @@ -202,4 +202,36 @@ describe('runScan — fail-closed invariants', () => { expect(result.trustworthy).toBe(true); expect(result.held).toEqual([BLOB1, BLOB2].sort()); }); + + test('onCollection fires once per collection (not once per batch)', async () => { + const api = makeApi({ + held: [BLOB1], + collections: [ + { + nsid: 'app.bsky.feed.post', + records: [ + makeRecord('app.bsky.feed.post', { text: 'a', embed: { images: [{ image: { $type: 'blob', ref: { $link: BLOB1 } } }] } }), + makeRecord('app.bsky.feed.post', { text: 'b', embed: { images: [{ image: { $type: 'blob', ref: { $link: BLOB1 } } }] } }), + makeRecord('app.bsky.feed.post', { text: 'c', embed: { images: [{ image: { $type: 'blob', ref: { $link: BLOB1 } } }] } }), + ], + }, + ], + }); + // Force two pages of records for the single collection + const orig = api.listRecords; + api.listRecords = async (params) => { + const res = await orig({ ...params, limit: 2 }); + return res; + }; + const emitted = []; + await runScan({ + did: DID, + ...api, + onProgress: { + onCollection: (nsid, records, blobRefs) => emitted.push({ nsid, records, blobRefs }), + }, + }); + expect(emitted).toHaveLength(1); + expect(emitted[0]).toEqual({ nsid: 'app.bsky.feed.post', records: 3, blobRefs: 3 }); + }); }); diff --git a/src/components/OrphanGrid.js b/src/components/OrphanGrid.js index 71e6c4c..57aca65 100644 --- a/src/components/OrphanGrid.js +++ b/src/components/OrphanGrid.js @@ -1,82 +1,75 @@ // src/components/OrphanGrid.js — orphan thumbnails, CIDs, MIME, size, selection // -// Metadata for orphans is NOT in any record (that's why they're orphans). -// Per the plan (§4) it is recovered via getBlob (Content-Type / Content-Length) -// once per CID and cached. Image blobs also get an object URL for the thumb. +// Rendering follows atproto.at's blob page: thumbnails come from Bluesky's +// image CDN (https://cdn.bsky.app/img/feed_thumbnail/plain/{did}/{cid}@jpeg), +// no auth, no fetch — a plain with an onError fallback to the generic +// icon for non-image blobs (video, captions, PDFs, …). +// +// Download fetches the blob through the authenticated agent (same-origin blob +// URL so the `download` attribute is honored cross-browser), falling back to +// a direct PDS getBlob navigation if the fetch fails. -import React, { useState, useEffect } from 'react'; +import React, { useState, useCallback } from 'react'; import { downloadBlob } from '../lib/backup'; const CID_PREFIX_LEN = 16; -// Don't hold object URLs for blobs larger than this (memory). -const THUMB_MAX_BYTES = 5 * 1024 * 1024; +const THUMB_CDN = (did, cid) => + `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${cid}@jpeg`; -// module-level cache: did+cid -> {mimeType, size, url|null, loading, failed} -const metaCache = new Map(); +const BlobThumb = ({ did, cid }) => { + const [failed, setFailed] = useState(false); -const useBlobMeta = ({ did, cid, agent }) => { - const [meta, setMeta] = useState(() => metaCache.get(`${did}:${cid}`) || null); + if (failed) { + return
; + } + return ( + setFailed(true)} + /> + ); +}; - useEffect(() => { - if (!agent || !did || !cid) return undefined; - const key = `${did}:${cid}`; - if (metaCache.has(key)) { - setMeta(metaCache.get(key)); - return undefined; - } - let cancelled = false; - metaCache.set(key, { loading: true, failed: false, mimeType: null, size: null, url: null }); - (async () => { +const OrphanCard = ({ cid, did, pdsUrl, agent, selected, onToggle, copiedCid, onCopy }) => { + const [downloading, setDownloading] = useState(false); + const directUrl = `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`; + + const handleDownload = useCallback( + async (e) => { + e.preventDefault(); + if (!agent) { + window.open(directUrl, '_blank', 'noopener'); + return; + } + setDownloading(true); try { const res = await agent.api.com.atproto.sync.getBlob({ did, cid }); - const headers = res?.headers || {}; - const get = (n) => (typeof headers.get === 'function' ? headers.get(n) : headers[n]); - const mimeType = get('content-type') || 'application/octet-stream'; - const size = Number.parseInt(get('content-length') || '0', 10) || 0; - let url = null; const data = res?.data; + let blob; if (data instanceof Blob) { - if (data.size <= THUMB_MAX_BYTES && mimeType.startsWith('image/')) { - url = URL.createObjectURL(data); - } + blob = data; } else if (data instanceof ArrayBuffer) { - if (data.byteLength <= THUMB_MAX_BYTES && mimeType.startsWith('image/')) { - url = URL.createObjectURL(new Blob([data], { type: mimeType })); - } - } - const entry = { loading: false, failed: false, mimeType, size, url }; - if (!cancelled) { - metaCache.set(key, entry); - setMeta(entry); + blob = new Blob([data]); + } else if (data && typeof data.byteLength === 'number') { + // Uint8Array (what getBlob actually returns) + blob = new Blob([data]); + } else { + throw new Error('unexpected getBlob response'); } + downloadBlob(blob, `${cid}.bin`); } catch (err) { - const entry = { loading: false, failed: true, mimeType: null, size: null, url: null }; - if (!cancelled) { - metaCache.set(key, entry); - setMeta(entry); - } + console.error(`Download failed for ${cid}:`, err); + window.open(directUrl, '_blank', 'noopener'); + } finally { + setDownloading(false); } - })(); - return () => { - cancelled = true; - }; - }, [did, cid, agent]); - - return meta; -}; - -const BlobThumb = ({ meta }) => { - if (!meta || meta.loading) { - return
; - } - if (meta.failed || !meta.url) { - return
; - } - return ; -}; + }, + [agent, did, cid, directUrl] + ); -const OrphanCard = ({ cid, pdsUrl, did, agent, selected, onToggle, copiedCid, onCopy, onDownload }) => { - const meta = useBlobMeta({ did, cid, agent }); return (
- + -
- {meta?.mimeType || '…'} - {meta?.size ? {(meta.size / 1024).toFixed(1)} KiB : null} -
- + + {downloading ? 'Downloading…' : 'Download'} +
); }; @@ -115,18 +110,6 @@ const OrphanGrid = ({ orphans, pdsUrl, did, agent, selected, allSelected, onTogg } }; - const downloadOne = async (cid, mimeType) => { - try { - const res = await agent.api.com.atproto.sync.getBlob({ did, cid }); - const data = res?.data; - const blob = - data instanceof Blob ? data : new Blob([data], { type: mimeType || 'application/octet-stream' }); - downloadBlob(blob, `${cid}.bin`); - } catch (err) { - console.error(`Download failed for ${cid}:`, err); - } - }; - return (
@@ -141,14 +124,13 @@ const OrphanGrid = ({ orphans, pdsUrl, did, agent, selected, allSelected, onTogg onToggleOne(cid)} copiedCid={copiedCid} onCopy={copyCid} - onDownload={downloadOne} /> ))}
diff --git a/src/lib/scan.js b/src/lib/scan.js index 7c70b88..a1e535f 100644 --- a/src/lib/scan.js +++ b/src/lib/scan.js @@ -125,7 +125,6 @@ export async function runScan({ col.blobRefs += 1; } } - onProgress.onCollection?.(nsid, col.records, col.blobRefs); onProgress.onRecord?.(col.records); const next = res?.data?.cursor; if (next !== undefined && next !== null && next !== '') { @@ -138,6 +137,9 @@ export async function runScan({ paging = false; } } + // Emit the collection row only once the whole collection has finished + // enumerating — not once per batch. + onProgress.onCollection?.(nsid, col.records, col.blobRefs); } // 4. Diff diff --git a/src/pages/Sifter.css b/src/pages/Sifter.css index 6d79062..d6014d0 100644 --- a/src/pages/Sifter.css +++ b/src/pages/Sifter.css @@ -196,12 +196,24 @@ } .download-one { + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; background: var(--navbar-bg); color: var(--text); border: 2px solid var(--card-border); + border-radius: 6px; min-height: 44px; padding: 8px 12px; font-size: 0.9rem; + font-weight: 700; + font-family: inherit; + cursor: pointer; +} + +.download-one:hover { + border-color: var(--button-bg); } .select-all {