feat(ext/sw): vault helpers for attachment add/remove

addAttachmentToItem appends an AttachmentRef + re-syncs the manifest
entry's attachment_summaries. removeAttachmentsFromItem returns the
removed refs so the caller can deleteBlob() the underlying bytes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-25 15:57:14 -04:00
parent 27ca91234f
commit 559c881dca

View File

@@ -3,7 +3,7 @@
import type { SessionHandle } from '../../wasm/relicario_wasm';
import type { GitHost } from './git-host';
import type { Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types';
import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wasm: any = null;
@@ -140,3 +140,83 @@ export function findByHostname(
.filter(([, e]) => e.trashed_at === undefined)
.filter(([, e]) => (e.icon_hint ?? '').toLowerCase() === h);
}
// --- Attachment helpers ---
/// Sync the manifest entry's attachment_summaries for a single item.
/// Reads the current manifest + item; if either is missing, does nothing.
async function syncManifestAttachments(
git: GitHost,
handle: SessionHandle,
itemId: ItemId,
item: Item,
message: string,
): Promise<void> {
const manifest = await fetchAndDecryptManifest(git, handle);
const entry = manifest.items[itemId];
if (!entry) return;
entry.attachment_summaries = item.attachments.map((a) => ({
id: a.id,
filename: a.filename,
mime_type: a.mime_type,
size: a.size,
}));
await encryptAndWriteManifest(git, handle, manifest, message);
}
/// Add an AttachmentRef to an existing item.
/// Reads + decrypts the item, appends to attachments[], re-encrypts and
/// writes both items/<id>.enc and the manifest entry's attachment_summaries.
/// Throws if the item is not found.
export async function addAttachmentToItem(
git: GitHost,
handle: SessionHandle,
itemId: ItemId,
ref: AttachmentRef,
): Promise<void> {
let item: Item;
try {
item = await fetchAndDecryptItem(git, handle, itemId);
} catch {
throw new Error(`addAttachmentToItem: item ${itemId} not found`);
}
item.attachments = [...item.attachments, ref];
item.modified = Math.floor(Date.now() / 1000);
const commitMsg = `attach ${ref.filename} to item ${itemId}`;
await encryptAndWriteItem(git, handle, itemId, item, commitMsg);
await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`);
}
/// 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
/// silently ignored.
export async function removeAttachmentsFromItem(
git: GitHost,
handle: SessionHandle,
itemId: ItemId,
idsToRemove: string[],
): Promise<AttachmentRef[]> {
let item: Item;
try {
item = await fetchAndDecryptItem(git, handle, itemId);
} catch {
throw new Error(`removeAttachmentsFromItem: item ${itemId} not found`);
}
const removeSet = new Set(idsToRemove);
const removed: AttachmentRef[] = [];
const kept: AttachmentRef[] = [];
for (const att of item.attachments) {
if (removeSet.has(att.id)) {
removed.push(att);
} else {
kept.push(att);
}
}
item.attachments = kept;
item.modified = Math.floor(Date.now() / 1000);
const commitMsg = `remove ${removed.length} attachment(s) from item ${itemId}`;
await encryptAndWriteItem(git, handle, itemId, item, commitMsg);
await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`);
return removed;
}