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 ``;
+ }).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();