From d2cb6d846183b7e9bd42b9152b819d1af2017700 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 26 Apr 2026 15:57:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(ext/sw):=20trash=20operations=20=E2=80=94?= =?UTF-8?q?=20listTrashed,=20restoreItem,=20purgeItem,=20purgeAllTrash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listTrashed filters manifest for trashed_at != null, sorted newest-first. restoreItem clears trashed_at. purgeItem deletes item + attachments. purgeAllTrash also scans for orphan blobs in attachments/ directory. Co-Authored-By: Claude --- .../service-worker/__tests__/trash.test.ts | 48 ++++++++++ .../src/service-worker/router/popup-only.ts | 37 +++++++- extension/src/service-worker/vault.ts | 95 +++++++++++++++++++ 3 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 extension/src/service-worker/__tests__/trash.test.ts diff --git a/extension/src/service-worker/__tests__/trash.test.ts b/extension/src/service-worker/__tests__/trash.test.ts new file mode 100644 index 0000000..1d11df6 --- /dev/null +++ b/extension/src/service-worker/__tests__/trash.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { listTrashed } from '../vault'; +import type { Manifest } from '../../shared/types'; + +function makeManifest(items: Record): Manifest { + const manifest: Manifest = { schema_version: 2, items: {} }; + for (const [id, { trashed_at }] of Object.entries(items)) { + manifest.items[id] = { + id, + type: 'login', + title: `Item ${id}`, + tags: [], + favorite: false, + modified: 1000, + trashed_at, + attachment_summaries: [], + }; + } + return manifest; +} + +describe('listTrashed', () => { + it('returns empty array when no trashed items', () => { + const manifest = makeManifest({ a: {}, b: {} }); + expect(listTrashed(manifest)).toEqual([]); + }); + + it('filters to only trashed items', () => { + const manifest = makeManifest({ + a: {}, + b: { trashed_at: 1000 }, + c: { trashed_at: 2000 }, + }); + const result = listTrashed(manifest); + expect(result).toHaveLength(2); + expect(result.map(([id]) => id)).toEqual(['c', 'b']); // sorted newest first + }); + + it('sorts by trashed_at descending', () => { + const manifest = makeManifest({ + old: { trashed_at: 100 }, + mid: { trashed_at: 500 }, + new: { trashed_at: 900 }, + }); + const result = listTrashed(manifest); + expect(result.map(([id]) => id)).toEqual(['new', 'mid', 'old']); + }); +}); diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index ab576e3..3ffb41e 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -315,11 +315,38 @@ export async function handle( return { ok: true }; } - // Handlers for these cases are added in Tasks 4–5. - case 'list_trashed': - case 'restore_item': - case 'purge_item': - case 'purge_all_trash': + case 'list_trashed': { + if (!state.manifest) return { ok: false, error: 'vault_locked' }; + const items = vault.listTrashed(state.manifest); + return { ok: true, data: { items } }; + } + + case 'restore_item': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + await vault.restoreItem(state.gitHost, handle, state.manifest, msg.id); + return { ok: true }; + } + + case 'purge_item': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + await vault.purgeItem(state.gitHost, msg.id, state.manifest); + await vault.encryptAndWriteManifest( + state.gitHost, handle, state.manifest, + `manifest: purge ${state.manifest.items[msg.id]?.title ?? msg.id}`, + ); + return { ok: true }; + } + + case 'purge_all_trash': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + const result = await vault.purgeAllTrash(state.gitHost, handle, state.manifest); + return { ok: true, data: result }; + } + + // Handler for this case is added in Task 5. case 'get_field_history': return { ok: false, error: 'not_implemented' }; } diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index 2c10a74..2b8c3b2 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -187,6 +187,101 @@ export async function addAttachmentToItem( await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`); } +// --- Trash operations --- + +export function listTrashed(manifest: Manifest): Array<[ItemId, ManifestEntry]> { + return Object.entries(manifest.items) + .filter(([, entry]) => entry.trashed_at != null) + .sort(([, a], [, b]) => (b.trashed_at ?? 0) - (a.trashed_at ?? 0)); +} + +export async function restoreItem( + git: GitHost, + handle: SessionHandle, + manifest: Manifest, + itemId: ItemId, +): Promise { + const item = await fetchAndDecryptItem(git, handle, itemId); + const now = Math.floor(Date.now() / 1000); + const restored: Item = { ...item, trashed_at: undefined, modified: now }; + await encryptAndWriteItem(git, handle, itemId, restored, `restore: ${item.title}`); + manifest.items[itemId] = { ...manifest.items[itemId], trashed_at: undefined, modified: now }; + await encryptAndWriteManifest(git, handle, manifest, `manifest: restore ${item.title}`); +} + +export async function purgeItem( + git: GitHost, + itemId: ItemId, + manifest: Manifest, +): Promise { + const entry = manifest.items[itemId]; + const deletedBlobs: string[] = []; + + // Delete attachments + for (const att of entry?.attachment_summaries ?? []) { + try { + await git.deleteBlob(`attachments/${att.id}.bin`, `purge attachment: ${att.filename}`); + deletedBlobs.push(att.id); + } catch { /* blob may not exist */ } + } + + // Delete item file + await git.deleteFile(`items/${itemId}.enc`, `purge: ${entry?.title ?? itemId}`); + + // Update manifest + delete manifest.items[itemId]; + + return deletedBlobs; +} + +export async function purgeAllTrash( + git: GitHost, + handle: SessionHandle, + manifest: Manifest, +): Promise<{ itemCount: number; orphanCount: number }> { + const trashed = listTrashed(manifest); + const allDeletedBlobs = new Set(); + + // Purge each trashed item + for (const [id] of trashed) { + const deleted = await purgeItem(git, id, manifest); + deleted.forEach((b) => allDeletedBlobs.add(b)); + } + + // Collect all referenced attachment IDs from remaining items + const referenced = new Set(); + for (const entry of Object.values(manifest.items)) { + for (const att of entry.attachment_summaries ?? []) { + referenced.add(att.id); + } + } + + // Scan for orphan blobs + let orphanCount = 0; + try { + const blobFiles = await git.listDir('attachments'); + for (const filename of blobFiles) { + const id = filename.replace(/\.bin$/, ''); + if (!referenced.has(id) && !allDeletedBlobs.has(id)) { + try { + await git.deleteBlob(`attachments/${filename}`, `purge orphan: ${id}`); + orphanCount++; + } catch { /* ignore */ } + } + } + } catch { /* attachments dir may not exist */ } + + // Write manifest once + if (trashed.length > 0 || orphanCount > 0) { + await encryptAndWriteManifest( + git, handle, manifest, + `trash: purge ${trashed.length} items + ${orphanCount} orphan blobs`, + ); + } + + return { itemCount: trashed.length, orphanCount }; +} + /// Remove attachments matching the given IDs from the item. /// Returns the removed AttachmentRefs (so the caller can deleteBlob the /// underlying bytes). IDs not present in the item's attachments are