Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/SMOKE_TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,9 @@ and at least one entity tagged on Device A.
| 7.1 | Open any article via the toolbar icon → reader opens, capture is cached | ✅ |
| 7.2 | Close the reader, navigate away, then re-capture the original URL | ✅ the reader's archive banner offers the cached copy |
| 7.3 | DevTools → Application → IndexedDB → `xray-archive` → `articles` | ✅ entry exists for the URL hash |
| 7.3a | On an open capture with claims, click the 🗑 header button (S4) | ✅ the confirm names the article, counts prior versions, and states what is KEPT (claims/audits by count, published relay events, source bytes) — never a bare "are you sure" |
| 7.3b | Confirm 7.3a's delete, then re-run 7.3 and re-capture the URL | ✅ the IndexedDB row is gone; claims still list in the reader for the URL; the fresh re-capture starts a NEW row with no prior versions |
| 7.3c | Options → Advanced → "Clear all X-Ray storage" | ✅ the confirm says SETTINGS only and points at "Start fresh workspace" for content — after confirming, entities/claims/archives are intact, relays/signing key are gone |

### Paywall fallback

Expand Down
12 changes: 11 additions & 1 deletion src/options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -946,7 +946,17 @@ async function clearLlmKey() {
}

async function clearAll() {
if (!confirm('Erase all X-Ray settings, entities, the keypair registry, and the local signing key? This cannot be undone.')) return;
// The confirm must promise exactly what storageClearExtension
// delivers. The old text claimed "entities, the keypair registry"
// — but those keys (publications/people/organizations/
// keypair_registry) are the LEGACY userscript stores; the modern
// workspace (entities, local_keys, claims, the archives) lives
// elsewhere and is untouched here. That is what "Start fresh
// workspace" (above, Advanced) is for — say so.
if (!confirm('Erase X-Ray SETTINGS: relays, preferences, the local signing key, '
+ 'the LLM API key, feature flags, and legacy userscript-era stores. '
+ 'Your workspace CONTENT (entities, claims, captured articles, archives) is NOT touched — '
+ 'use "Start fresh workspace" for that. This cannot be undone. Continue?')) return;
await storageClearExtension();
// Reset experimental flags too — otherwise a wipe leaves the
// public judgment-publishing path enabled.
Expand Down
1 change: 1 addition & 0 deletions src/reader/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
<button class="xr-reader__btn xr-reader__btn--ghost" id="xr-entities" title="Open entity browser in the side panel">
👤 Entities
</button>
<button class="xr-reader__btn xr-reader__btn--ghost" id="xr-delete-capture" title="Delete the local archived copy of this capture">🗑</button>
<button class="xr-reader__btn xr-reader__btn--ghost" id="xr-close" title="Close tab">✕</button>
<button class="xr-reader__btn xr-reader__btn--primary" id="xr-publish" title="Publish to NOSTR">
Publish
Expand Down
82 changes: 82 additions & 0 deletions src/reader/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,73 @@ function confirmLlmSend(headline, body, actionLabel) {
});
}

/**
* Delete the local archived copy of the open capture — with an honest
* account of what that does and does not remove. The row disappears
* from case membership, the corpus list, and the archive banner; the
* in-memory capture in this tab is untouched until the tab closes.
*/
async function deleteCaptureFlow() {
const url = state.article && state.article.url;
if (!url) throw new Error('No capture open.');
if (state.readOnlyOpen) throw new Error('Read-only view — nothing to delete.');

// Find the row the load path would find: primary URL, then the
// alias-resolved original (a prior capture of the same piece may
// key under either address).
let rowUrl = url;
let row = null;
try { row = await ArchiveCache.getArticle(url); } catch (_) { /* absent */ }
if (!row) {
try {
const aliased = await resolveAlias(url);
if (aliased && aliased !== url) {
row = await ArchiveCache.getArticle(aliased);
if (row) rowUrl = aliased;
}
} catch (_) { /* absent */ }
}
if (!row) {
toast('No local archived copy exists for this URL — nothing to delete.', 'warning', 4000);
return;
}

// The honest confirm: what goes, what stays. Counts are the
// difference between an informed choice and a guess.
const priors = Array.isArray(row.priorVersions) ? row.priorVersions.length : 0;
let claimCount = 0;
let auditCount = 0;
try { claimCount = (await ClaimModel.getBySourceUrl(url)).length; } catch (_) { /* best-effort */ }
try {
const hashes = [row.articleHash,
...(row.priorVersions || []).map((v) => v && v.articleHash)].filter(Boolean);
const runs = [];
for (const h of hashes) runs.push(...await AuditRunModel.getByArticleHash(h));
auditCount = runs.length;
} catch (_) { /* best-effort */ }

const lines = [
`Delete the local archived copy of:\n${row.article && row.article.title ? row.article.title : rowUrl}`,
'',
`DELETED: the archived copy${priors ? ` and its ${priors} prior version${priors === 1 ? '' : 's'}` : ''}. It disappears from case membership, the corpus, and the archive banner.`,
'',
'KEPT: '
+ `${claimCount} claim${claimCount === 1 ? '' : 's'} and ${auditCount} audit run${auditCount === 1 ? '' : 's'} on this URL (they key to the URL/hash, not the copy); `
+ 'anything already PUBLISHED to relays (relays cannot be forced to delete); '
+ 'archived source bytes, which the orphan pruner collects later.',
'',
'This cannot be undone locally. Delete?'
].join('\n');
if (!confirm(lines)) return;

await ArchiveCache.deleteArticle(rowUrl);
// Both addresses may hold rows from before the alias was learned —
// deleting a missing row is a harmless no-op.
if (rowUrl !== url) { try { await ArchiveCache.deleteArticle(url); } catch (_) { /* no-op */ } }

toast('Local archived copy deleted. This tab still shows the in-memory capture until closed.', 'success', 6000);
}

/** Minimal modal file picker; resolves with the chosen File. */
function pickPdfFile(message) {
return new Promise((resolvePick, rejectPick) => {
Expand Down Expand Up @@ -5981,6 +6048,21 @@ async function init() {
window.close();
});

// Delete the LOCAL archived copy (S4 — deleteArticle finally gets a
// caller). Scope is deliberately narrow and the confirm says so:
// local row + its prior versions only. Claims/assessments/audits
// key to the URL/hash in their own stores and survive; published
// relay events cannot be deleted from here (NIP-09 requests are
// advisory and relays ignore them freely — pretending otherwise
// would be dishonest); archived source bytes (PDFs, figures) are
// collected by the age-gated orphan pruner once unreferenced.
$('#xr-delete-capture').addEventListener('click', () => {
deleteCaptureFlow().catch((err) => {
toast('Delete failed: ' + ((err && err.message) || err), 'error', 5000);
});
});
if (state.readOnlyOpen) $('#xr-delete-capture').hidden = true;

// Open the entity browser. Three openers, in preference order:
// 1. browser.sidebarAction.toggle() — Firefox sidebar
// 2. chrome.sidePanel.open() — Chrome / Edge / Brave
Expand Down
26 changes: 26 additions & 0 deletions tests/archive-cache.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,29 @@ test('prune: a re-captured document is protected by a refreshed grace window', a
assert.ok(await getSourceDocument(HASH),
'bytes of an in-flight re-capture must survive the pruner');
});

test('archive: delete removes the row TOGETHER with its prior versions (S4)', async () => {
await resetStore();
// Two saves with different bodies → the second displaces the first
// into priorVersions; delete must take the whole row, snapshot
// included — a dangling prior would resurrect "deleted" content.
await saveArticle({ article: { url: 'https://example.com/s4', title: 'v1', content: 'first body' } });
await saveArticle({ article: { url: 'https://example.com/s4', title: 'v2', content: 'second body, different' } });
const before = await getArticle('https://example.com/s4');
assert.ok(Array.isArray(before.priorVersions) && before.priorVersions.length === 1,
'precondition: the displaced version was snapshotted');
await deleteArticle('https://example.com/s4');
assert.equal(await getArticle('https://example.com/s4'), null);
assert.equal(await hasArticle('https://example.com/s4'), false);
});

test('archive: deleting an absent row is a harmless no-op (S4)', async () => {
await resetStore();
await assert.doesNotReject(() => deleteArticle('https://example.com/never-saved'));
// And URL-normalization applies: delete by a tracking-param variant
// removes the row saved under the canonical form.
await saveArticle({ article: { url: 'https://example.com/s4b', title: 'T' } });
await deleteArticle('https://example.com/s4b?utm_source=x');
assert.equal(await hasArticle('https://example.com/s4b'), false,
'delete joins through the same normalizer as save');
});
Loading