feat(ext/popup): Document item type — form + signature-block detail

Form requires title + primary_attachment; the primary-row picker is
compact in edit mode (dashed-border when empty, filename row when
filled). Detail view promotes the primary to a gold signature block
(48×60 thumb + filename + meta + ↓ download · 🔍 preview). For image-
mime primaries, the thumb lazy-loads via decrypt + object-URL; the
preview button toggles an inline expanded view.

Supplementary attachments use the standard compact disclosure (Task 7)
when present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-25 18:58:52 -04:00
parent 6ef7aaca53
commit 705b171553
3 changed files with 571 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
const getState = vi.fn(() => ({
view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
searchQuery: '', activeGroup: null, error: null, loading: false,
capturedTabId: null, capturedUrl: '', newType: 'document',
}));
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return { navigate, setState, sendMessage, getState, escapeHtml };
});
import { renderForm } from '../document';
import { sendMessage } from '../../../popup';
import type { Item, AttachmentRef } from '../../../../shared/types';
const PRIMARY: AttachmentRef = {
id: 'primaryid',
filename: 'passport.pdf',
mime_type: 'application/pdf',
size: 240000,
created: 1700000000,
};
describe('Document form save', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
});
it('rejects save when primary_attachment is missing', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'Passport';
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
document.getElementById('save-btn')!.click();
await new Promise((r) => setTimeout(r, 50));
expect(alertSpy).not.toHaveBeenCalled();
// setState called with the error
const { setState } = await import('../../../popup');
expect(vi.mocked(setState)).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('primary attachment') }),
);
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
expect(addCall).toBeUndefined();
});
it('sends update_item with correct wire shape when valid', async () => {
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
const existingDraft: Item = {
id: 'docid',
type: 'document',
core: {
type: 'document',
filename: PRIMARY.filename,
mime_type: PRIMARY.mime_type,
primary_attachment: PRIMARY.id,
},
title: 'Passport',
tags: [],
favorite: false,
created: 0,
modified: 0,
trashed_at: undefined,
sections: [],
attachments: [PRIMARY],
field_history: {},
};
const app = document.getElementById('app')!;
renderForm(app, 'edit', existingDraft);
document.getElementById('save-btn')!.click();
await new Promise((r) => setTimeout(r, 50));
const calls = vi.mocked(sendMessage).mock.calls;
const updateCall = calls.find(([msg]) => (msg as { type: string }).type === 'update_item');
expect(updateCall).toBeDefined();
const msg = updateCall![0] as { type: 'update_item'; item: any };
expect(msg.item.core.primary_attachment).toBe(PRIMARY.id);
expect(msg.item.core.type).toBe('document');
expect(msg.item.attachments).toEqual([PRIMARY]);
expect(msg.item.type).toBe('document');
});
});

View File

@@ -0,0 +1,376 @@
/// Document item type — title + REQUIRED primary attachment + optional
/// notes/tags + optional supplementary attachments.
/// Primary attachment is referenced by ID from the item's attachments array.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import {
renderSectionsEditor, wireSectionsEditor,
} from '../fields';
import {
renderAttachmentsDisclosure,
wireAttachmentsDisclosure,
teardownAttachmentsDisclosure,
} from '../attachments-disclosure';
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
let sectionsExpanded = false;
export function teardown(): void {
teardownAttachmentsDisclosure();
if (activeKeyHandler) {
document.removeEventListener('keydown', activeKeyHandler);
activeKeyHandler = null;
}
if (activeFormEscHandler) {
document.removeEventListener('keydown', activeFormEscHandler);
activeFormEscHandler = null;
}
sectionsExpanded = false;
}
const formatBytes = (n: number): string => {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
};
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
teardown();
const state = getState();
const isEdit = mode === 'edit';
const c = existing?.core.type === 'document' ? existing.core : null;
const sectionsDraft: Section[] = existing
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
: [];
let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
let primaryId: string = c?.primary_attachment ?? '';
const renderPrimary = (): string => {
const primaryRef = attachmentsDraft.find((a) => a.id === primaryId);
if (!primaryRef) {
return `
<div class="document-primary-row document-primary-row--empty" id="primary-picker">
+ attach primary file
</div>
`;
}
return `
<div class="document-primary-row" id="primary-picker">
<span class="document-primary-row__thumb">📄</span>
<span class="document-primary-row__name">${escapeHtml(primaryRef.filename)}</span>
<span class="document-primary-row__meta">${formatBytes(primaryRef.size)}</span>
<span class="document-primary-row__action">↑ change</span>
</div>
`;
};
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${isEdit ? 'edit document' : 'new document'}</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group">
<label class="label" for="f-title">title <span class="req">*</span></label>
<input id="f-title" type="text" value="${escapeHtml(existing?.title ?? '')}" placeholder="Passport, Lease, etc.">
</div>
<div class="form-group">
<label class="label">primary attachment <span class="req">*</span></label>
${renderPrimary()}
<input type="file" id="primary-file-input" hidden />
</div>
<div class="form-group">
<label class="label" for="f-notes">notes</label>
<textarea id="f-notes" rows="3" placeholder="optional context...">${escapeHtml(existing?.notes ?? '')}</textarea>
</div>
<div class="form-group">
<label class="label" for="f-tags">tags</label>
<input id="f-tags" type="text" value="${escapeHtml((existing?.tags ?? []).join(', '))}" placeholder="legal, official">
</div>
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
${renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' })}
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">save</button>
</div>
</div>
`;
const wirePrimaryPicker = (): void => {
const picker = document.getElementById('primary-picker');
const fileInput = document.getElementById('primary-file-input') as HTMLInputElement | null;
picker?.addEventListener('click', () => fileInput?.click());
fileInput?.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
const bytes = await file.arrayBuffer();
const resp = await sendMessage({
type: 'upload_attachment',
itemId: existing?.id ?? '',
filename: file.name,
mimeType: file.type || 'application/octet-stream',
bytes,
});
if (resp && resp.ok) {
const data = resp.data as { attachment: AttachmentRef };
attachmentsDraft = [...attachmentsDraft.filter((a) => a.id !== primaryId), data.attachment];
primaryId = data.attachment.id;
const picker2 = document.getElementById('primary-picker');
if (picker2) {
picker2.outerHTML = renderPrimary();
wirePrimaryPicker();
}
} else {
alert(`upload failed: ${resp?.error ?? 'service worker unavailable'}`);
}
fileInput.value = '';
});
};
wirePrimaryPicker();
const rerender = (): void => {
const disclosure = app.querySelector('.disclosure');
if (!disclosure) return;
sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true';
disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded);
wireSectionsEditor(app, sectionsDraft, rerender);
};
wireSectionsEditor(app, sectionsDraft, rerender);
const wireDisclosure = (): void => {
wireAttachmentsDisclosure(app, {
itemId: existing?.id ?? '',
attachments: attachmentsDraft,
mode: 'edit',
onChange: (next) => {
attachmentsDraft = next;
// If the user removed the primary, clear primaryId so save validation will block
if (!attachmentsDraft.find((a) => a.id === primaryId)) primaryId = '';
const disc = app.querySelector('.attachments-disclosure');
if (disc) {
disc.outerHTML = renderAttachmentsDisclosure({
itemId: existing?.id ?? '',
attachments: attachmentsDraft,
mode: 'edit',
});
wireDisclosure();
}
},
});
};
wireDisclosure();
document.getElementById('cancel-btn')?.addEventListener('click', () => {
setState({ error: null });
navigate(isEdit ? 'detail' : 'list');
});
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveDocument(mode, existing, primaryId, attachmentsDraft, sectionsDraft);
});
const escHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setState({ error: null });
navigate(isEdit ? 'detail' : 'list');
}
};
activeFormEscHandler = escHandler;
document.addEventListener('keydown', escHandler);
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
}
async function saveDocument(
mode: 'add' | 'edit',
existing: Item | null,
primaryId: string,
attachmentsDraft: AttachmentRef[],
sectionsDraft: Section[],
): Promise<void> {
const state = getState();
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
if (!title) { setState({ error: 'Title is required' }); return; }
const primaryRef = attachmentsDraft.find((a) => a.id === primaryId);
if (!primaryId || !primaryRef) {
setState({ error: 'primary attachment required' });
return;
}
const notesRaw = (document.getElementById('f-notes') as HTMLTextAreaElement).value.trim();
const tagsRaw = (document.getElementById('f-tags') as HTMLInputElement).value;
const tags = tagsRaw.split(',').map((t) => t.trim()).filter(Boolean);
const core = {
type: 'document' as const,
filename: primaryRef.filename,
mime_type: primaryRef.mime_type,
primary_attachment: primaryId,
};
const now = Math.floor(Date.now() / 1000);
const item: Item = {
id: existing?.id ?? '',
title,
type: 'document',
tags,
favorite: existing?.favorite ?? false,
group: existing?.group,
notes: notesRaw || undefined,
created: existing?.created ?? now,
modified: now,
trashed_at: undefined,
core,
sections: sectionsDraft,
attachments: attachmentsDraft,
field_history: existing?.field_history ?? {},
};
setState({ loading: true, error: null });
const resp = mode === 'add'
? await sendMessage({ type: 'add_item', item })
: await sendMessage({ type: 'update_item', id: state.selectedId!, item });
if (resp.ok) {
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
} else navigate('list');
} else {
setState({ loading: false, error: resp.error });
}
}
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
teardown();
if (item.core.type !== 'document') return;
const c = item.core;
const primaryRef = item.attachments.find((a) => a.id === c.primary_attachment);
if (!primaryRef) {
app.innerHTML = `<div class="pad"><p>document missing primary attachment</p></div>`;
return;
}
const isImageMime = primaryRef.mime_type.startsWith('image/');
const supplementary = item.attachments.filter((a) => a.id !== c.primary_attachment);
const tagsHtml = item.tags && item.tags.length > 0
? `<div class="form-group"><label class="label">tags</label><span>${item.tags.map((t) => escapeHtml(t)).join(', ')}</span></div>`
: '';
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:12px;">${escapeHtml(item.title)}</div>
<div class="document-signature-block" id="doc-sigblock">
<div class="document-signature-block__thumb" data-att-id="${escapeHtml(primaryRef.id)}" data-mime="${escapeHtml(primaryRef.mime_type)}">📄</div>
<div class="document-signature-block__info">
<div class="document-signature-block__name">${escapeHtml(primaryRef.filename)}</div>
<div class="document-signature-block__meta">${formatBytes(primaryRef.size)} · ${new Date(primaryRef.created * 1000).toISOString().slice(0, 10)}</div>
<div class="document-signature-block__actions">
<span id="doc-download" style="cursor:pointer;color:#d2ab43;">↓ download</span>
${isImageMime ? '<span id="doc-preview" style="cursor:pointer;color:#d2ab43;margin-left:10px;">🔍 preview</span>' : ''}
</div>
</div>
</div>
${item.notes ? `<div class="form-group"><label class="label">notes</label><p>${escapeHtml(item.notes)}</p></div>` : ''}
${tagsHtml}
${supplementary.length > 0 ? renderAttachmentsDisclosure({ itemId: item.id, attachments: supplementary, mode: 'view' }) : ''}
<div class="form-actions" style="margin-top:14px;">
<button class="btn" id="back-btn">back</button>
<button class="btn" id="edit-btn">edit</button>
<button class="btn danger" id="trash-btn">trash</button>
</div>
</div>
`;
// Lazy-load thumb for image-mime primaries
if (isImageMime) {
const thumb = document.querySelector('.document-signature-block__thumb') as HTMLElement;
sendMessage({ type: 'download_attachment', itemId: item.id, attachmentId: primaryRef.id }).then((resp) => {
if (!resp || !resp.ok) return;
const data = resp.data as { bytes: ArrayBuffer };
const blob = new Blob([data.bytes], { type: primaryRef.mime_type });
const url = URL.createObjectURL(blob);
if (thumb) thumb.innerHTML = `<img src="${url}" alt="" />`;
}).catch(() => {});
}
document.getElementById('doc-download')?.addEventListener('click', async () => {
const resp = await sendMessage({ type: 'download_attachment', itemId: item.id, attachmentId: primaryRef.id });
if (!resp || !resp.ok) { alert(`download failed: ${resp?.error ?? 'service worker unavailable'}`); return; }
const data = resp.data as { bytes: ArrayBuffer };
const blob = new Blob([data.bytes], { type: primaryRef.mime_type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = primaryRef.filename;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
});
document.getElementById('doc-preview')?.addEventListener('click', async () => {
const sigblock = document.getElementById('doc-sigblock')!;
const existingPreview = sigblock.querySelector('.document-signature-block__preview');
if (existingPreview) { existingPreview.remove(); return; }
const resp = await sendMessage({ type: 'download_attachment', itemId: item.id, attachmentId: primaryRef.id });
if (!resp || !resp.ok) return;
const data = resp.data as { bytes: ArrayBuffer };
const blob = new Blob([data.bytes], { type: primaryRef.mime_type });
const url = URL.createObjectURL(blob);
const preview = document.createElement('div');
preview.className = 'document-signature-block__preview';
preview.innerHTML = `<img src="${url}" alt="" />`;
sigblock.appendChild(preview);
});
// Wire supplementary disclosure
if (supplementary.length > 0) {
wireAttachmentsDisclosure(app, { itemId: item.id, attachments: supplementary, mode: 'view' });
}
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
document.getElementById('trash-btn')?.addEventListener('click', async () => {
if (!confirm(`Move "${item.title}" to trash?`)) return;
teardown();
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
} else navigate('list');
});
const handler = async (e: KeyboardEvent) => {
const t = e.target;
if (t instanceof HTMLElement) {
const tag = t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
}
switch (e.key) {
case 'Escape': teardown(); navigate('list'); break;
case 'e': teardown(); navigate('edit'); break;
case 'd':
e.preventDefault();
if (confirm(`Move "${item.title}" to trash?`)) {
teardown();
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
} else navigate('list');
}
break;
}
};
activeKeyHandler = handler;
document.addEventListener('keydown', handler);
}

View File

@@ -892,3 +892,109 @@ textarea {
border-color: #aa812a; border-color: #aa812a;
color: #c9d1d9; color: #c9d1d9;
} }
/* --- Document type signature block + primary picker (γ₁) --- */
.document-signature-block {
border-left: 3px solid #aa812a;
background: #161b22;
padding: 10px;
margin: 8px 0;
border-radius: 0 4px 4px 0;
display: flex;
align-items: center;
gap: 10px;
}
.document-signature-block__thumb {
width: 48px;
height: 60px;
border-radius: 2px;
background: linear-gradient(135deg, #b88a30, #7c5719);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
overflow: hidden;
color: #fff3cf;
}
.document-signature-block__thumb img {
width: 100%; height: 100%; object-fit: contain;
}
.document-signature-block__info { flex: 1; min-width: 0; }
.document-signature-block__name {
font-size: 11px;
color: #f1cf6e;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-signature-block__meta {
font-size: 9px;
color: #8b949e;
font-family: ui-monospace, monospace;
margin-top: 2px;
}
.document-signature-block__actions {
font-size: 9px;
margin-top: 4px;
}
.document-signature-block__preview {
margin-top: 8px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 3px;
padding: 6px;
text-align: center;
}
.document-signature-block__preview img {
max-width: 100%;
max-height: 200px;
border-radius: 2px;
}
/* Document primary picker (form mode) */
.document-primary-row {
background: #161b22;
border: 1px solid #30363d;
border-radius: 4px;
padding: 6px 8px;
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
cursor: pointer;
}
.document-primary-row--empty {
border-style: dashed;
border-color: #aa812a;
color: #8b949e;
justify-content: center;
padding: 10px 8px;
}
.document-primary-row__thumb {
width: 18px; height: 18px;
border-radius: 2px;
background: linear-gradient(135deg, #b88a30, #7c5719);
display: flex; align-items: center; justify-content: center;
font-size: 10px; flex-shrink: 0;
}
.document-primary-row__name {
flex: 1;
color: #c9d1d9;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-primary-row__meta {
color: #6e7681;
font-size: 9px;
font-family: ui-monospace, monospace;
}
.document-primary-row__action {
color: #d2ab43;
font-size: 10px;
padding: 0 6px;
cursor: pointer;
}