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:
48
extension/src/service-worker/__tests__/trash.test.ts
Normal file
48
extension/src/service-worker/__tests__/trash.test.ts
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -315,11 +315,38 @@ export async function handle(
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers for these cases are added in Tasks 4–5.
|
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' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user