import { defineStore } from "pinia"; import { ref } from "vue"; import { api } from "../api/client"; import { useUiStore } from "./ui"; import type { NoteColor } from "../notes/colors"; export type NoteView = "active" | "archived" | "trash"; export type NoteKind = "text" | "list"; export interface NoteLabel { id: string; name: string; color: string; // True when this label is attached because of a #tag in the note body (kept in // sync with the text); false = added manually via the picker. via_tag: boolean; } export interface ChecklistItem { id: string; text: string; checked: boolean; position: number; } export interface Attachment { id: string; url: string; mime: string; } // A past version of a note's title+body (version history). export interface NoteRevision { id: string; title: string | null; body: string; created_at: string | null; } export interface Note { id: string; title: string | null; // The note's display NAME: explicit title, else its first body line (server-derived). // Every note has one, so body-only notes are still nameable + [[link]]-able. display_title: string; body: string; color: NoteColor; kind: NoteKind; position: number; pinned: boolean; archived: boolean; trashed: boolean; remind_at: string | null; labels: NoteLabel[]; items: ChecklistItem[]; attachments: Attachment[]; created_at: string | null; updated_at: string | null; } export const useNotesStore = defineStore("notes", () => { const items = ref([]); const loading = ref(false); const view = ref("active"); const activeLabel = ref(null); function sortItems(): void { items.value.sort((a, b) => { if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; if (a.position !== b.position) return b.position - a.position; return (b.updated_at ?? "").localeCompare(a.updated_at ?? ""); }); } function belongsHere(n: Note): boolean { const v = view.value; const inView = v === "trash" ? n.trashed : v === "archived" ? !n.trashed && n.archived : !n.trashed && !n.archived; if (!inView) return false; if (activeLabel.value) return n.labels.some((lb) => lb.id === activeLabel.value); return true; } function reconcile(note: Note): void { const idx = items.value.findIndex((n) => n.id === note.id); if (belongsHere(note)) { if (idx >= 0) items.value[idx] = note; else items.value.push(note); sortItems(); } else if (idx >= 0) { items.value.splice(idx, 1); } } async function load(v: NoteView, labelId: string | null = null): Promise { view.value = v; activeLabel.value = labelId; loading.value = true; try { const query = labelId ? `/api/notes?filter=${v}&label=${labelId}` : `/api/notes?filter=${v}`; const res = await api.get<{ notes: Note[] }>(query); items.value = res.notes; sortItems(); } finally { loading.value = false; } } async function create(input: { title: string; body: string; color: NoteColor; kind?: NoteKind; items?: string[]; }): Promise { const note = await api.post("/api/notes", input); reconcile(note); return note; } async function mutate( id: string, changes: Partial>, ): Promise { reconcile(await api.patch(`/api/notes/${id}`, changes)); } const setPinned = (id: string, pinned: boolean) => mutate(id, { pinned }); const setArchived = async (id: string, archived: boolean): Promise => { await mutate(id, { archived }); if (archived) useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) }); }; const setColor = (id: string, color: NoteColor) => mutate(id, { color }); const setKind = (id: string, kind: NoteKind) => mutate(id, { kind }); const setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt }); const saveEdit = (id: string, changes: { title: string; body: string; color: NoteColor }) => mutate(id, changes); async function setLabels(id: string, labelIds: string[]): Promise { reconcile(await api.put(`/api/notes/${id}/labels`, { label_ids: labelIds })); } async function addItem(id: string, text: string): Promise { reconcile(await api.post(`/api/notes/${id}/items`, { text })); } async function updateItem(id: string, itemId: string, changes: { text?: string; checked?: boolean }): Promise { reconcile(await api.patch(`/api/notes/${id}/items/${itemId}`, changes)); } async function deleteItem(id: string, itemId: string): Promise { reconcile(await api.del(`/api/notes/${id}/items/${itemId}`)); } async function uploadAttachment(id: string, file: File): Promise { const form = new FormData(); form.append("file", file); const resp = await fetch(`/api/notes/${id}/attachments`, { method: "POST", credentials: "include", body: form }); const data: unknown = await resp.json().catch(() => ({})); if (!resp.ok) { const message = typeof data === "object" && data !== null && "error" in data ? String((data as { error: unknown }).error) : "Upload failed."; throw { error: message, status: resp.status }; } reconcile(data as Note); } async function deleteAttachment(id: string, attId: string): Promise { reconcile(await api.del(`/api/notes/${id}/attachments/${attId}`)); } async function importNotes(file: File): Promise<{ source: string; imported: number; skipped: number }> { const form = new FormData(); form.append("file", file); const resp = await fetch("/api/notes/import", { method: "POST", credentials: "include", body: form }); const data: unknown = await resp.json().catch(() => ({})); if (!resp.ok) { const message = typeof data === "object" && data !== null && "error" in data ? String((data as { error: unknown }).error) : "Import failed."; throw { error: message, status: resp.status }; } // Refresh the current lens so imported notes appear (labels reloaded by caller). await load(view.value, activeLabel.value); return data as { source: string; imported: number; skipped: number }; } async function fetchOne(id: string): Promise { try { return await api.get(`/api/notes/${id}`); } catch { return null; } } async function createTitled(title: string): Promise { const created = await api.post("/api/notes", { title, body: "" }); reconcile(created); return created; } async function reorder(orderedIds: string[]): Promise { // Optimistically assign positions matching the backend (total - index), sort, // then persist. const total = orderedIds.length; orderedIds.forEach((id, index) => { const n = items.value.find((x) => x.id === id); if (n) n.position = total - index; }); sortItems(); await api.post("/api/notes/reorder", { ids: orderedIds }); } async function trash(id: string): Promise { reconcile(await api.post(`/api/notes/${id}/trash`)); useUiStore().showToast("Note moved to trash", { label: "Undo", run: () => void restore(id) }); } async function restore(id: string): Promise { reconcile(await api.post(`/api/notes/${id}/restore`)); } async function deleteForever(id: string): Promise { await api.del(`/api/notes/${id}`); const idx = items.value.findIndex((n) => n.id === id); if (idx >= 0) items.value.splice(idx, 1); } async function fetchRevisions(id: string): Promise { const res = await api.get<{ revisions: NoteRevision[] }>(`/api/notes/${id}/revisions`); return res.revisions; } async function restoreRevision(id: string, revId: string): Promise { const note = await api.post(`/api/notes/${id}/revisions/${revId}/restore`); reconcile(note); return note; } return { items, loading, view, activeLabel, load, create, setPinned, setArchived, setColor, setKind, setReminder, saveEdit, setLabels, addItem, updateItem, deleteItem, uploadAttachment, deleteAttachment, importNotes, fetchOne, createTitled, reorder, trash, restore, deleteForever, fetchRevisions, restoreRevision, }; });