feat(ext/sw): trash operations — listTrashed, restoreItem, purgeItem, purgeAllTrash

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 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-26 15:57:08 -04:00
parent 0003c3e658
commit d2cb6d8461
3 changed files with 175 additions and 5 deletions

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { listTrashed } from '../vault';
import type { Manifest } from '../../shared/types';
function makeManifest(items: Record<string, { trashed_at?: number }>): 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']);
});
});

View File

@@ -315,11 +315,38 @@ export async function handle(
return { ok: true }; return { ok: true };
} }
// Handlers for these cases are added in Tasks 45. case 'list_trashed': {
case 'list_trashed': if (!state.manifest) return { ok: false, error: 'vault_locked' };
case 'restore_item': const items = vault.listTrashed(state.manifest);
case 'purge_item': return { ok: true, data: { items } };
case 'purge_all_trash': }
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': case 'get_field_history':
return { ok: false, error: 'not_implemented' }; return { ok: false, error: 'not_implemented' };
} }

View File

@@ -187,6 +187,101 @@ export async function addAttachmentToItem(
await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`); 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<void> {
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<string[]> {
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<string>();
// 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<string>();
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. /// Remove attachments matching the given IDs from the item.
/// Returns the removed AttachmentRefs (so the caller can deleteBlob the /// Returns the removed AttachmentRefs (so the caller can deleteBlob the
/// underlying bytes). IDs not present in the item's attachments are /// underlying bytes). IDs not present in the item's attachments are