diff --git a/extension/src/popup/components/types/__tests__/document.save.test.ts b/extension/src/popup/components/types/__tests__/document.save.test.ts new file mode 100644 index 0000000..543cfe0 --- /dev/null +++ b/extension/src/popup/components/types/__tests__/document.save.test.ts @@ -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, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + 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 = '
'; + 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'); + }); +}); diff --git a/extension/src/popup/components/types/document.ts b/extension/src/popup/components/types/document.ts new file mode 100644 index 0000000..57822dd --- /dev/null +++ b/extension/src/popup/components/types/document.ts @@ -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 ` +
+ + attach primary file +
+ `; + } + return ` +
+ ๐Ÿ“„ + ${escapeHtml(primaryRef.filename)} + ${formatBytes(primaryRef.size)} + โ†‘ change +
+ `; + }; + + app.innerHTML = ` +
+
${isEdit ? 'edit document' : 'new document'}
+ ${state.error ? `
${escapeHtml(state.error)}
` : ''} +
+ + +
+
+ + ${renderPrimary()} + +
+
+ + +
+
+ + +
+ ${renderSectionsEditor(sectionsDraft, sectionsExpanded)} + ${renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' })} +
+ + +
+
+ `; + + 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 { + 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 { + 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 = `

document missing primary attachment

`; + 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 + ? `
${item.tags.map((t) => escapeHtml(t)).join(', ')}
` + : ''; + + app.innerHTML = ` +
+
${escapeHtml(item.title)}
+ +
+
๐Ÿ“„
+
+
${escapeHtml(primaryRef.filename)}
+
${formatBytes(primaryRef.size)} ยท ${new Date(primaryRef.created * 1000).toISOString().slice(0, 10)}
+
+ โ†“ download + ${isImageMime ? '๐Ÿ” preview' : ''} +
+
+
+ + ${item.notes ? `

${escapeHtml(item.notes)}

` : ''} + ${tagsHtml} + + ${supplementary.length > 0 ? renderAttachmentsDisclosure({ itemId: item.id, attachments: supplementary, mode: 'view' }) : ''} + +
+ + + +
+
+ `; + + // 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 = ``; + }).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 = ``; + 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); +} diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index 54f69ad..189cb9e 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -892,3 +892,109 @@ textarea { border-color: #aa812a; 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; +}