import { toast } from "solid-sonner"; import { pb } from "@/lib/pocketbase"; import { NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants"; import { buildNoteTag, normalizeNoteKey } from "@/lib/tags"; import { relationFilter } from "./contexts"; import { activeNoteId, setActiveNoteId, type Note, type Task, setStore, store, unwrapRelationId } from "./state"; import { getTaskListFields, mapRecordToTask, updateTask } from "./tasks"; let notesLoadPromise: Promise | null = null; let deferredNotesLoadTimer: number | undefined; export const loadedNoteDetailIds = new Set(); export const failedNoteDetailIds = new Set(); export const mapRecordToNote = (r: any, includeContent = true): Note => { return { id: r.id, title: r.title, key: r.key || normalizeNoteKey(r.title), content: includeContent ? r.content : undefined, tags: Array.isArray(r.tags) && r.tags.length > 0 ? r.tags : [buildNoteTag(r.key || r.title)], isPrivate: r.isPrivate || false, user: unwrapRelationId(r.user) || "", tasks: r.tasks || [], created: r.created, updated: r.updated, deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined }; }; const reconcileFetchedNotes = (records: any[], includeContent = false) => { const existingById = new Map(store.notes.map(note => [note.id, note])); const nextNotes = records.map((record: any) => { const mapped = mapRecordToNote(record, includeContent); const existing = existingById.get(mapped.id); if (!includeContent && existing?.content !== undefined) { mapped.content = existing.content; } return mapped; }); setStore("notes", nextNotes); }; export const fetchNoteById = async (id: string): Promise => { try { const record = await pb.collection(NOTES_COLLECTION).getOne(id); if (!record) return null; return mapRecordToNote(record); } catch (err) { console.error("Failed to fetch note by ID:", err); return null; } }; export const updateStoreWithNote = (note: Note) => { failedNoteDetailIds.delete(note.id); if (note.content !== undefined) { loadedNoteDetailIds.add(note.id); } setStore("notes", currentNotes => { const index = currentNotes.findIndex(n => n.id === note.id); if (index !== -1) { const newNotes = [...currentNotes]; newNotes[index] = note; return newNotes; } return [...currentNotes, note]; }); }; export const loadNotesMetadata = async () => { if (!pb.authStore.isValid) return; if (store.hasLoadedNotesMetadata) return; if (notesLoadPromise) return notesLoadPromise; const currentUserId = pb.authStore.model?.id; setStore("isNotesLoading", true); notesLoadPromise = (async () => { try { const notesRecords = await pb.collection(NOTES_COLLECTION).getFullList({ filter: `isPrivate = false || ${relationFilter("user", currentUserId)}`, sort: "-created", fields: "id,created,updated,title,key,tags,isPrivate,user,tasks,deletedAt", requestKey: null }); reconcileFetchedNotes(notesRecords, false); setStore("hasLoadedNotesMetadata", true); } catch (notesErr) { console.warn("Failed to load notes (collection might not exist yet):", notesErr); } finally { setStore("isNotesLoading", false); notesLoadPromise = null; } })(); return notesLoadPromise; }; export const scheduleDeferredNotesMetadataLoad = () => { if (!pb.authStore.isValid || store.hasLoadedNotesMetadata || notesLoadPromise || deferredNotesLoadTimer) return; deferredNotesLoadTimer = window.setTimeout(() => { deferredNotesLoadTimer = undefined; void loadNotesMetadata(); }, 0); }; export const requestImmediateNotesMetadataLoad = async () => { if (deferredNotesLoadTimer) { clearTimeout(deferredNotesLoadTimer); deferredNotesLoadTimer = undefined; } await loadNotesMetadata(); }; export const loadNoteContent = async (id: string) => { if (!id) return; if (loadedNoteDetailIds.has(id)) return; if (failedNoteDetailIds.has(id)) return; if (store.loadingNoteContentIds.includes(id)) return; setStore("loadingNoteContentIds", ids => ids.includes(id) ? ids : [...ids, id]); try { const currentUserId = pb.authStore.model?.id; const visibilityFilter = `id = "${id}" && (isPrivate = false || ${relationFilter("user", currentUserId)})`; const record = await pb.collection(NOTES_COLLECTION).getFirstListItem(visibilityFilter, { requestKey: `note-content-${id}` }); const note = mapRecordToNote(record, true); updateStoreWithNote(note); loadedNoteDetailIds.add(id); } catch (err) { const status = (err as any)?.status; if (status === 404) { failedNoteDetailIds.add(id); return; } console.error("Failed to load note content:", err); } finally { setStore("loadingNoteContentIds", ids => ids.filter(existingId => existingId !== id)); } }; export const uploadNoteAttachment = async (noteId: string, file: File): Promise<{ url: string, filename: string }> => { try { const formData = new FormData(); formData.append("attachments+", file); const record = await pb.collection(NOTES_COLLECTION).update(noteId, formData); if (record.attachments && record.attachments.length > 0) { const latestFilename = record.attachments[record.attachments.length - 1]; return { url: pb.files.getURL(record, latestFilename), filename: latestFilename }; } throw new Error("File uploaded but no attachment returned"); } catch (err: any) { console.error("Error uploading note attachment. Validation details:", JSON.stringify(err.data, null, 2)); toast.error("Failed to upload image to note."); throw err; } }; export const cleanupNoteAttachments = async (noteId: string) => { if (!pb.authStore.isValid) return; try { const record = await pb.collection(NOTES_COLLECTION).getOne(noteId); if (!record.attachments || record.attachments.length === 0) return; const content = record.content || ""; const filesToDelete: string[] = []; const decodedContent = decodeURIComponent(content); for (const filename of record.attachments) { if (!content.includes(filename) && !decodedContent.includes(filename)) { filesToDelete.push(filename); } } if (filesToDelete.length > 0) { await pb.collection(NOTES_COLLECTION).update(noteId, { "attachments-": filesToDelete }); console.log(`[CLEANUP] Deleted ${filesToDelete.length} orphaned attachment(s) for note ${noteId}`); } } catch (err) { if ((err as any)?.status === 404) return; console.error("[CLEANUP] Failed to clean up orphaned note attachments:", err); } }; const removeDeletedNoteFromStore = (id: string) => { if (activeNoteId() === id) { setActiveNoteId(null); } loadedNoteDetailIds.delete(id); failedNoteDetailIds.delete(id); setStore("loadingNoteContentIds", ids => ids.filter(existingId => existingId !== id)); setStore("notes", notes => notes.filter(note => note.id !== id)); }; const removeNoteReferencesFromTask = (task: Task, note: Note): Partial | null => { const noteKey = (note.key || normalizeNoteKey(note.title)).toLowerCase(); const noteTag = buildNoteTag(note.key || note.title).toLowerCase(); const nextNoteRefs = task.noteRefs.filter(ref => ref.noteId !== note.id && ref.key.toLowerCase() !== noteKey ); const nextShareRefs = task.shareRefs.filter(ref => !(ref.kind === "note" && (ref.contextId === note.id || ref.key.toLowerCase() === noteKey)) ); const nextTags = (task.tags || []).filter(tag => tag.toLowerCase() !== noteTag); const noteRefsChanged = nextNoteRefs.length !== task.noteRefs.length; const shareRefsChanged = nextShareRefs.length !== task.shareRefs.length; const tagsChanged = nextTags.length !== (task.tags || []).length; if (!noteRefsChanged && !shareRefsChanged && !tagsChanged) { return null; } return { tags: nextTags, noteRefs: nextNoteRefs, shareRefs: nextShareRefs }; }; const cleanupTasksForPermanentlyDeletedNote = async (note: Note) => { const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({ fields: getTaskListFields(), requestKey: null }); const affectedTasks = taskRecords .filter((record: any) => !record.tags?.includes("__template__")) .map((record: any) => mapRecordToTask(record)) .filter(task => !!removeNoteReferencesFromTask(task, note)); for (const task of affectedTasks) { const cleanup = removeNoteReferencesFromTask(task, note); if (!cleanup) continue; await updateTask(task.id, cleanup); } }; export const updateNote = async (id: string, updates: Partial) => { if (!pb.authStore.isValid) return; if (updates.content !== undefined) { loadedNoteDetailIds.add(id); } const optimisticUpdates = { ...updates, key: updates.key || (updates.title ? normalizeNoteKey(updates.title) : undefined), updated: new Date().toISOString() }; setStore("notes", n => n.id === id, optimisticUpdates); try { const pbUpdates: any = { ...updates }; if ("deletedAt" in updates) { if (updates.deletedAt) { pbUpdates.deletedAt = new Date(updates.deletedAt).toISOString(); } else { pbUpdates.deletedAt = null; } } if (!pbUpdates.key && updates.title) { pbUpdates.key = normalizeNoteKey(updates.title); } await pb.collection(NOTES_COLLECTION).update(id, pbUpdates, { requestKey: null }); } catch (err) { console.error("Note update failed", err); toast.error("Failed to update note."); } }; export const removeNote = (id: string) => { updateNote(id, { deletedAt: Date.now() }); }; export const restoreNote = (id: string) => { updateNote(id, { deletedAt: null }); }; export const deleteNotePermanently = async (id: string) => { if (!pb.authStore.isValid) return; const note = store.notes.find(existing => existing.id === id) || await fetchNoteById(id); if (note) { await cleanupTasksForPermanentlyDeletedNote(note); } removeDeletedNoteFromStore(id); try { await pb.collection(NOTES_COLLECTION).delete(id); } catch (err) { console.error("Note delete failed", err); toast.error("Failed to delete note."); } }; export const loadTasksLinkedToNote = async (note: Pick) => { if (!pb.authStore.isValid || !note?.id) return; const noteTag = buildNoteTag(note.key || note.title); const escapedNoteTag = noteTag.replace(/"/g, '\\"'); try { const listRecords = await pb.collection(TASGRID_COLLECTION).getFullList({ filter: `(tags ~ "${escapedNoteTag}") && tags !~ "__template__"`, sort: "-updated", fields: getTaskListFields(), requestKey: `note-linked-${note.id}` }).catch(() => []); const recordMap = new Map(); listRecords.forEach(record => { if (!record?.id) return; if (record.tags?.includes("__template__")) return; recordMap.set(record.id, record); }); if (recordMap.size === 0) return; setStore("tasks", currentTasks => { const taskMap = new Map(currentTasks.map(task => [task.id, task])); recordMap.forEach((record: any) => { const mapped = mapRecordToTask(record); const existing = taskMap.get(mapped.id); if (existing?.content) { mapped.content = existing.content; } taskMap.set(mapped.id, mapped); }); return Array.from(taskMap.values()); }); } catch (err: any) { console.error("Failed to load note-linked tasks:", err?.status, err?.response || err); } };