import { writable, derived } from 'svelte/store'; import type { Note, NoteFilter, NoteCreate, NoteUpdate } from '$lib/types/index.js'; import { addToSyncQueue, putNote, deleteNoteFromIdb, getAllNotes, clearNotes as clearIdbNotes } from '$lib/sync/idb.js'; import { showToast } from '$lib/stores/toast.js'; import { extractTags } from '$lib/utils/tags.js'; export const notes = writable([]); export const currentFilter = writable('all'); export const selectedTag = writable(null); export const notesLoaded = writable(false); export const searchQuery = writable(''); export const searchResults = writable([]); /** * Reconcile search results against the canonical notes store so local edits and * trashing propagate into the active result set without re-querying the server. * * A result absent from the canonical store is kept as-is — it's a server hit not * yet loaded locally (e.g. created on another device), and dropping it would make * valid matches disappear mid-session. Notes removed locally (deleted or left) are * pruned explicitly at their call sites; trashed notes are dropped here. */ export function reconcileSearchResults(canonicalNotes: Note[], results: Note[]): Note[] { const notesMap = new Map(canonicalNotes.map((note) => [note.id, note])); return results.flatMap((result) => { const canonical = notesMap.get(result.id); if (!canonical) return [result]; return canonical.trashed ? [] : [canonical]; }); } // Keep searchResults in sync with edits/trashing in the notes store. These search // stores (like the others in this module) are client-only state; the subscription // lives for the app's lifetime and is intentionally never unsubscribed. notes.subscribe(($notes) => { searchResults.update(($results) => { if ($results.length === 0) return $results; return reconcileSearchResults($notes, $results); }); }); export const filteredNotes = derived( [notes, selectedTag, currentFilter, searchQuery, searchResults], ([$notes, $selectedTag, $filter, $searchQuery, $searchResults]) => { let result = $searchQuery.trim() ? $searchResults : $notes; if ($filter === 'all') { result = result.filter((n) => !n.trashed && !n.archived); } else if ($filter === 'archived') { result = result.filter((n) => n.archived && !n.trashed); } else if ($filter === 'trashed') { result = result.filter((n) => n.trashed); } if ($selectedTag) { result = result.filter((n) => n.tags?.includes($selectedTag)); } return result; } ); function sortNotes(list: Note[]): Note[] { return [...list].sort((a, b) => { const orderDiff = a.sortOrder - b.sortOrder; if (orderDiff !== 0) return orderDiff; return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); }); } export const pinnedNotes = derived(filteredNotes, ($notes) => sortNotes($notes.filter((n) => n.pinned)) ); export const unpinnedNotes = derived(filteredNotes, ($notes) => sortNotes($notes.filter((n) => !n.pinned)) ); export const allTags = derived(notes, ($notes) => { const tagSet = new Set(); $notes.forEach((n) => n.tags?.forEach((t) => tagSet.add(t))); return [...tagSet].sort(); }); function isNetworkError(err: unknown): boolean { return err instanceof TypeError; } /** * Merge incoming notes with current store, keeping the higher-version note * when a conflict exists. This prevents a stale loadNotes response from * overwriting a just-saved note (race between sync interval and updateNote). */ export function mergeNotesByVersion(current: Note[], incoming: Note[]): Note[] { const currentMap = new Map(current.map((n) => [n.id, n])); const incomingIds = new Set(); const merged = incoming.map((incomingNote) => { incomingIds.add(incomingNote.id); const local = currentMap.get(incomingNote.id); if (local && local.version > incomingNote.version) return local; return incomingNote; }); // Preserve local-only notes (e.g. just created, not yet in server/IDB response) for (const local of current) { if (!incomingIds.has(local.id)) { merged.push(local); } } return merged; } export async function loadNotes(filter: NoteFilter = 'all') { // Load from IDB first for instant display try { const cached = await getAllNotes(); if (cached.length > 0) { notes.update((current) => current.length > 0 ? mergeNotesByVersion(current, cached) : cached); } } catch { // IDB unavailable — continue with server fetch } currentFilter.set(filter); notesLoaded.set(true); // Then fetch from server in the background try { const res = await fetch(`/api/notes?filter=${filter}`); if (res.ok) { const data: Note[] = await res.json(); notes.update((current) => mergeNotesByVersion(current, data)); currentFilter.set(filter); // Update IDB with server state try { await clearIdbNotes(); await Promise.all(data.map((n) => putNote(n))); } catch { // IDB write failed — server data is still in the store } } else if (res.status === 401) { window.location.href = '/login'; } else { showToast('Failed to load notes', 'error'); } } catch { // Offline — IDB data already loaded above } } export async function createNote(note: NoteCreate): Promise { try { const res = await fetch('/api/notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(note) }); if (res.ok) { const created: Note = await res.json(); notes.update((list) => [created, ...list]); await putNote(created); return created; } if (res.status === 401) { window.location.href = '/login'; } else { showToast('Failed to save note', 'error'); } return null; } catch (err) { if (isNetworkError(err)) { const now = new Date(); const title = note.title ?? ''; const content = note.content ?? ''; const optimistic: Note = { id: crypto.randomUUID(), title, content, color: note.color ?? 'default', pinned: note.pinned ?? false, archived: false, trashed: false, trashedAt: null, checklistMode: note.checklistMode ?? false, sortOrder: 0, createdAt: now, updatedAt: now, version: 1, tags: extractTags(`${title} ${content}`) }; notes.update((list) => [optimistic, ...list]); await putNote(optimistic); await addToSyncQueue({ noteId: optimistic.id, operation: 'create', data: optimistic, timestamp: Date.now() }); showToast('Saved offline — will sync when reconnected', 'info'); return optimistic; } return null; } } export async function updateNote(id: string, updates: NoteUpdate): Promise { // Read current version for 3-way merge base on the server let baseVersion: number | undefined; notes.update((list) => { const current = list.find((n) => n.id === id); if (current) baseVersion = current.version; return list; }); try { const res = await fetch(`/api/notes/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...updates, baseVersion }) }); if (res.ok) { const updated: Note = await res.json(); notes.update((list) => list.map((n) => (n.id === id ? updated : n))); await putNote(updated); return updated; } showToast('Failed to save note', 'error'); return null; } catch (err) { if (isNetworkError(err)) { let optimistic: Note | null = null; notes.update((list) => list.map((n) => { if (n.id === id) { optimistic = { ...n, ...updates, updatedAt: new Date() }; if (updates.title !== undefined || updates.content !== undefined) { optimistic.tags = extractTags(`${optimistic.title} ${optimistic.content}`); } return optimistic; } return n; }) ); if (optimistic) { await putNote(optimistic); await addToSyncQueue({ noteId: id, operation: 'update', data: updates, timestamp: Date.now(), baseVersion }); showToast('Saved offline — will sync when reconnected', 'info'); } return optimistic; } return null; } } export async function deleteNote(id: string): Promise { try { const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' }); if (res.ok) { notes.update((list) => list.filter((n) => n.id !== id)); searchResults.update((list) => list.filter((n) => n.id !== id)); await deleteNoteFromIdb(id); return true; } return false; } catch (err) { if (isNetworkError(err)) { notes.update((list) => list.filter((n) => n.id !== id)); searchResults.update((list) => list.filter((n) => n.id !== id)); await deleteNoteFromIdb(id); await addToSyncQueue({ noteId: id, operation: 'delete', timestamp: Date.now() }); showToast('Saved offline — will sync when reconnected', 'info'); return true; } return false; } } export async function trashNote(id: string): Promise { const result = await updateNote(id, { trashed: true }); if (result) notes.update((list) => list.filter((n) => n.id !== id)); return result; } export async function restoreNote(id: string): Promise { return updateNote(id, { trashed: false }); } export async function archiveNote(id: string): Promise { const result = await updateNote(id, { archived: true }); if (result) notes.update((list) => list.filter((n) => n.id !== id)); return result; } export async function unarchiveNote(id: string): Promise { return updateNote(id, { archived: false }); } export async function togglePin(id: string, currentPinned: boolean): Promise { return updateNote(id, { pinned: !currentPinned }); } export async function leaveNote(id: string): Promise { try { const res = await fetch(`/api/notes/${id}/collaborators?userId=self`, { method: 'DELETE' }); if (res.ok) { notes.update((list) => list.filter((n) => n.id !== id)); searchResults.update((list) => list.filter((n) => n.id !== id)); await deleteNoteFromIdb(id); return true; } showToast('Failed to leave note', 'error'); return false; } catch { return false; } } export async function updateSortOrders(orders: { id: string; sortOrder: number }[]): Promise { // Optimistic update notes.update((list) => list.map((n) => { const order = orders.find((o) => o.id === n.id); return order ? { ...n, sortOrder: order.sortOrder } : n; }) ); try { const res = await fetch('/api/notes/reorder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ orders }) }); return res.ok; } catch { // Offline — optimistic update stands return false; } }