From e9d520fd5e511b1da56b24fff95083181e61549c Mon Sep 17 00:00:00 2001 From: aewing Date: Sat, 28 Mar 2026 19:43:30 +0000 Subject: [PATCH] Add attachments workflow to notes workspace --- notes-workspace.html | 228 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 224 insertions(+), 4 deletions(-) diff --git a/notes-workspace.html b/notes-workspace.html index 109d562..5567b9a 100644 --- a/notes-workspace.html +++ b/notes-workspace.html @@ -167,6 +167,15 @@ +
+
+ + + No attachments selected. +
+ +
+
@@ -183,6 +192,11 @@ No selection
+
+
Selected Note Attachments
+
Select a note to view attachments.
+
+
@@ -198,12 +212,16 @@ const PB_DEFAULT_AUTH_COLLECTION = 'Users'; const PB_DEFAULT_OAUTH_PROVIDER = 'microsoft'; const NOTES_COLLECTION = 'Notes'; + const ATTACHMENTS_COLLECTION = 'Attachments'; + const ATTACHMENTS_FILE_FIELD = 'file'; + const ATTACHMENTS_PBID_FIELD = 'pb_id'; let pb = null; let pbConfigPromise = null; let allNotes = []; let currentFilter = 'all'; let selectedNote = null; + let pendingAttachments = []; const output = document.getElementById('output'); const healthStatus = document.getElementById('healthStatus'); @@ -219,6 +237,11 @@ const shareInput = document.getElementById('shareInput'); const searchInput = document.getElementById('searchInput'); const hiddenMode = document.getElementById('hiddenMode'); + const attachmentsInput = document.getElementById('attachmentsInput'); + const pendingAttachmentsSummary = document.getElementById('pendingAttachmentsSummary'); + const pendingAttachmentsList = document.getElementById('pendingAttachmentsList'); + const selectedAttachmentsStatus = document.getElementById('selectedAttachmentsStatus'); + const selectedAttachmentsList = document.getElementById('selectedAttachmentsList'); const saveBtn = document.getElementById('saveBtn'); const hideBtn = document.getElementById('hideBtn'); @@ -283,6 +306,162 @@ return []; } + function attachmentKindFromName(name) { + const fileName = String(name || '').toLowerCase(); + if (!fileName) return 'file'; + if (/(\.jpg|\.jpeg|\.png|\.gif|\.webp|\.bmp|\.svg)$/.test(fileName)) return 'image'; + if (/(\.mp4|\.mov|\.avi|\.webm|\.mkv|\.m4v)$/.test(fileName)) return 'video'; + if (/(\.mp3|\.wav|\.m4a|\.aac|\.ogg|\.flac|\.webm)$/.test(fileName)) return 'audio'; + return 'file'; + } + + function getAttachmentFileNames(att) { + const value = att?.[ATTACHMENTS_FILE_FIELD]; + if (Array.isArray(value)) return value.filter(Boolean); + if (typeof value === 'string' && value.trim()) return [value.trim()]; + return []; + } + + function buildKindSetFromAttachments(atts) { + const kinds = new Set(); + (atts || []).forEach((att) => { + getAttachmentFileNames(att).forEach((name) => kinds.add(attachmentKindFromName(name))); + }); + return kinds; + } + + function kindIcon(kind) { + if (kind === 'image') return '๐Ÿ–ผ๏ธ'; + if (kind === 'video') return '๐ŸŽฌ'; + if (kind === 'audio') return '๐ŸŽต'; + return '๐Ÿ“Ž'; + } + + function renderPendingAttachments() { + if (!pendingAttachments.length) { + pendingAttachmentsSummary.textContent = 'No attachments selected.'; + pendingAttachmentsList.innerHTML = ''; + return; + } + + pendingAttachmentsSummary.textContent = `${pendingAttachments.length} file(s) selected`; + pendingAttachmentsList.innerHTML = pendingAttachments.map((file, index) => { + const kind = attachmentKindFromName(file.name); + return `
+
${kindIcon(kind)} ${escapeHtml(file.name)}
+ +
`; + }).join(''); + + pendingAttachmentsList.querySelectorAll('[data-pending-remove]').forEach((btn) => { + btn.addEventListener('click', () => { + const idx = Number.parseInt(btn.getAttribute('data-pending-remove') || '-1', 10); + if (Number.isInteger(idx) && idx >= 0 && idx < pendingAttachments.length) { + pendingAttachments.splice(idx, 1); + renderPendingAttachments(); + } + }); + }); + } + + async function uploadPendingAttachments(noteId) { + if (!pendingAttachments.length) return []; + const created = []; + + for (const sourceFile of pendingAttachments) { + const uploadFile = sourceFile instanceof File + ? sourceFile + : new File([sourceFile], sourceFile?.name || `attachment-${Date.now()}`, { type: sourceFile?.type || 'application/octet-stream' }); + + const fd = new FormData(); + fd.append(ATTACHMENTS_FILE_FIELD, uploadFile); + fd.append(ATTACHMENTS_PBID_FIELD, noteId); + const createdRecord = await pb.collection(ATTACHMENTS_COLLECTION).create(fd); + created.push(createdRecord); + } + + pendingAttachments = []; + renderPendingAttachments(); + return created; + } + + async function fetchAttachmentsForNote(noteId) { + try { + const atts = await pb.collection(ATTACHMENTS_COLLECTION).getFullList({ + sort: '-created', + filter: `${ATTACHMENTS_PBID_FIELD} = "${String(noteId || '').replace(/"/g, '\\"')}"`, + batch: 200, + }); + return Array.isArray(atts) ? atts : []; + } catch { + return []; + } + } + + async function enrichNotesWithAttachments(notes) { + const list = Array.isArray(notes) ? notes : []; + await Promise.all(list.map(async (note) => { + note._attachments = await fetchAttachmentsForNote(note.id); + })); + return list; + } + + async function renderSelectedAttachments() { + if (!selectedNote?.id) { + selectedAttachmentsStatus.textContent = 'Select a note to view attachments.'; + selectedAttachmentsList.innerHTML = ''; + return; + } + + selectedAttachmentsStatus.textContent = 'Loading attachments...'; + const noteRef = allNotes.find((n) => n.id === selectedNote.id) || selectedNote; + const atts = Array.isArray(noteRef._attachments) ? noteRef._attachments : await fetchAttachmentsForNote(selectedNote.id); + noteRef._attachments = atts; + selectedNote._attachments = atts; + + if (!atts.length) { + selectedAttachmentsStatus.textContent = 'No attachments uploaded.'; + selectedAttachmentsList.innerHTML = ''; + return; + } + + selectedAttachmentsStatus.textContent = `${atts.length} attachment record(s)`; + selectedAttachmentsList.innerHTML = atts.map((att) => { + const names = getAttachmentFileNames(att); + if (!names.length) return ''; + + const links = names.map((name) => { + const url = pb.files.getURL(att, name); + const kind = attachmentKindFromName(name); + return `
+ ${kindIcon(kind)} ${escapeHtml(name)} + +
`; + }).join(''); + + return `
${links}
`; + }).join(''); + + selectedAttachmentsList.querySelectorAll('[data-attachment-delete]').forEach((btn) => { + btn.addEventListener('click', async () => { + try { + const attId = btn.getAttribute('data-attachment-delete'); + if (!attId) return; + await pb.collection(ATTACHMENTS_COLLECTION).delete(attId); + const refreshedAtts = await fetchAttachmentsForNote(selectedNote.id); + selectedNote._attachments = refreshedAtts; + const match = allNotes.find((n) => n.id === selectedNote.id); + if (match) match._attachments = refreshedAtts; + renderList(); + await renderSelectedAttachments(); + writeOutput({ message: 'Attachment deleted', attachmentId: attId, noteId: selectedNote.id }); + } catch (error) { + writeOutput({ error: error?.message || String(error) }); + } + }); + }); + } + function toBoolFlag(value) { if (typeof value === 'boolean') return value; if (typeof value === 'number') return value !== 0; @@ -395,6 +574,12 @@ const mine = isMine(note); const sharedWithMe = isSharedWithCurrentUser(note); const sharedByMe = toBoolFlag(note?.shared); + const atts = note._attachments || []; + const typeSet = buildKindSetFromAttachments(atts); + const attachmentSummary = ['image', 'video', 'audio', 'file'] + .filter((kind) => typeSet.has(kind)) + .map((kind) => `${kindIcon(kind)} ${kind}`) + .join(' ยท '); const badge = sharedWithMe ? 'Shared w/ me' : sharedByMe ? 'Shared by me' : 'Private'; const tone = sharedWithMe ? 'text-cyan-300' : sharedByMe ? 'text-emerald-300' : 'text-slate-300'; const preview = String(note.body_plain || '').replace(/\s+/g, ' ').trim().slice(0, 160); @@ -408,6 +593,7 @@
${escapeHtml(note.type || 'personal')} ${note.Job_Number ? `Job ${escapeHtml(note.Job_Number)}` : ''} + ${atts.length ? `${escapeHtml(attachmentSummary || `${atts.length} attachment(s)`)}` : ''} ${escapeHtml(new Date(note.updated || note.created).toLocaleString())}

${escapeHtml(preview || '(empty)')}

@@ -422,6 +608,7 @@ if (note) { selectNote(note); renderList(); + renderSelectedAttachments(); } }); }); @@ -435,6 +622,10 @@ jobNumberInput.value = ''; bodyInput.value = ''; shareInput.value = ''; + pendingAttachments = []; + renderPendingAttachments(); + selectedAttachmentsStatus.textContent = 'Select a note to view attachments.'; + selectedAttachmentsList.innerHTML = ''; saveBtn.disabled = true; hideBtn.disabled = true; deleteBtn.disabled = true; @@ -448,6 +639,8 @@ jobNumberInput.value = String(note.Job_Number || ''); bodyInput.value = String(note.body_plain || ''); shareInput.value = normalizeShares(note.shared_with).join(', '); + pendingAttachments = []; + renderPendingAttachments(); saveBtn.disabled = false; hideBtn.disabled = false; deleteBtn.disabled = false; @@ -547,12 +740,14 @@ ...note, _sharedWithMe: isSharedWithCurrentUser(note), })); + await enrichNotesWithAttachments(allNotes); if (selectedNote) { const refreshed = allNotes.find((n) => n.id === selectedNote.id); selectedNote = refreshed || null; if (selectedNote) selectNote(selectedNote); } renderList(); + await renderSelectedAttachments(); writeOutput({ message: `Loaded ${allNotes.length} note(s)` }); } @@ -561,10 +756,13 @@ ensureLoggedIn(); const payload = buildNotePayload(); const record = await pb.collection(NOTES_COLLECTION).create(payload); + const uploaded = await uploadPendingAttachments(record.id); await loadNotes(); - selectNote(record); + const refreshed = allNotes.find((n) => n.id === record.id) || record; + selectNote(refreshed); renderList(); - writeOutput({ message: 'Note created', id: record.id }); + await renderSelectedAttachments(); + writeOutput({ message: 'Note created', id: record.id, attachmentsUploaded: uploaded.length }); } async function saveNote() { @@ -574,10 +772,13 @@ const payload = buildNotePayload(); payload.hidden = toBoolFlag(selectedNote.hidden); const record = await pb.collection(NOTES_COLLECTION).update(selectedNote.id, payload); + const uploaded = await uploadPendingAttachments(selectedNote.id); await loadNotes(); - selectNote(record); + const refreshed = allNotes.find((n) => n.id === selectedNote.id) || record; + selectNote(refreshed); renderList(); - writeOutput({ message: 'Note updated', id: record.id }); + await renderSelectedAttachments(); + writeOutput({ message: 'Note updated', id: record.id, attachmentsUploaded: uploaded.length }); } async function toggleHidden() { @@ -622,6 +823,24 @@ bind('hideBtn', toggleHidden); bind('deleteBtn', softDelete); + document.getElementById('pickFilesBtn').addEventListener('click', () => { + attachmentsInput.click(); + }); + + attachmentsInput.addEventListener('change', (e) => { + const files = Array.from(e.target.files || []); + if (files.length) { + pendingAttachments.push(...files); + renderPendingAttachments(); + } + attachmentsInput.value = ''; + }); + + document.getElementById('clearFilesBtn').addEventListener('click', () => { + pendingAttachments = []; + renderPendingAttachments(); + }); + document.getElementById('clearBtn').addEventListener('click', () => { clearForm(); renderList(); @@ -639,6 +858,7 @@ bootstrapWorkspace(); clearForm(); + renderPendingAttachments(); renderList(); loadHealth();