import { defineStore } from "pinia"; import { ref } from "vue"; import { repo } from "../adapters"; import { useUiStore } from "./ui"; export type NoteView = "active" | "archived" | "trash"; // Combinable facet filters for the board (mirrors the GET /api/notes query + a saved // view's stored params). All optional; empty = the plain, unfiltered board. export interface NoteFacets { q?: string; label?: string[]; has_reminder?: boolean; has_attachment?: boolean; created_after?: string; created_before?: string; } export interface NoteLabel { id: string; name: string; color: string; // True when the label is backed by text STILL IN THE BODY — a `#tag` written // mid-sentence, kept in sync with those words. False covers both a label added // through the picker and a tag lifted off a line of its own (M311), which is why // it is also what decides whether a chip can be removed with a cross. // // The card reads it the other way round: a true here means the body is already // showing this tag, so the chip would be the second copy and is not drawn. via_tag: boolean; } export interface ChecklistItem { id: string; text: string; checked: boolean; position: number; } export interface Attachment { id: string; url: string; filename?: string | null; mime: string; size?: number; sha256?: string | null; } // A cached OpenGraph/meta preview for a URL in the note (server-fetched, SSRF-guarded). export interface LinkPreview { id: string; url: string; title: string | null; description: string | null; image_url: string | null; site_name: string | null; } // A past version of a note's body (version history). export interface NoteRevision { id: string; body: string; created_at: string | null; } export interface Note { id: string; // The note's NAME: its first body line, else its first checklist item // (server-derived). Every note has one, so every note has something to be called. display_title: string; body: string; position: number; pinned: boolean; archived: boolean; trashed: boolean; // When it was trashed (null unless trashed). The Trash view counts the retention // window from here to show how long the note has left before it's purged. deleted_at: string | null; remind_at: string | null; recurrence: string | null; labels: NoteLabel[]; items: ChecklistItem[]; attachments: Attachment[]; previews: LinkPreview[]; 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); const activeFacets = ref({}); 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, facets: NoteFacets = {}): Promise { view.value = v; activeLabel.value = labelId; activeFacets.value = facets; loading.value = true; try { items.value = await repo.notes.list({ view: v, labelId, facets }); sortItems(); } finally { loading.value = false; } } async function create(input: { body: string; items?: string[] }): Promise { const note = await repo.notes.create(input); reconcile(note); return note; } async function mutate( id: string, changes: Partial>, ): Promise { reconcile(await repo.notes.update(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 setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt }); const setRecurrence = (id: string, recurrence: string | null) => mutate(id, { recurrence }); const saveEdit = (id: string, changes: { body: string }) => mutate(id, changes); async function completeReminder(id: string): Promise { reconcile(await repo.notes.completeReminder(id)); } async function snoozeReminder(id: string, minutes: number): Promise { reconcile(await repo.notes.snoozeReminder(id, minutes)); } async function setLabels(id: string, labelIds: string[]): Promise { reconcile(await repo.notes.setLabels(id, labelIds)); } async function addItem(id: string, text: string): Promise { reconcile(await repo.notes.addItem(id, text)); } async function updateItem(id: string, itemId: string, changes: { text?: string; checked?: boolean }): Promise { reconcile(await repo.notes.updateItem(id, itemId, changes)); } async function deleteItem(id: string, itemId: string): Promise { reconcile(await repo.notes.deleteItem(id, itemId)); } async function uploadAttachment(id: string, file: File): Promise { reconcile(await repo.notes.uploadAttachment(id, file)); } async function deleteAttachment(id: string, attId: string): Promise { reconcile(await repo.notes.deleteAttachment(id, attId)); } async function unfurl(id: string, url: string): Promise { reconcile(await repo.notes.unfurl(id, url)); } async function deletePreview(id: string, previewId: string): Promise { reconcile(await repo.notes.deletePreview(id, previewId)); } async function importNotes(file: File): Promise<{ source: string; imported: number; skipped: number }> { const data = await repo.notes.import(file); // Refresh the current lens so imported notes appear (labels reloaded by caller). await load(view.value, activeLabel.value, activeFacets.value); return data; } async function fetchOne(id: string): Promise { try { return await repo.notes.get(id); } catch { return null; } } 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 repo.notes.reorder(orderedIds); } async function trash(id: string): Promise { reconcile(await repo.notes.trash(id)); useUiStore().showToast("Note moved to trash", { label: "Undo", run: () => void restore(id) }); } async function restore(id: string): Promise { reconcile(await repo.notes.restore(id)); } async function deleteForever(id: string): Promise { // Guarded HERE rather than at the call sites (NoteCard and NoteEditor both offer // it) so the two can't drift on the one action with no undo. Trash is the // reversible step and already offers Undo; this is the point of no return — and // since sync propagates a tombstone, it reaches every linked device too. const note = items.value.find((n) => n.id === id); const title = note?.display_title.trim(); const subject = title ? `"${title}"` : "this note"; const confirmed = window.confirm( `Permanently delete ${subject}?\n\n` + "This can't be undone, and it will be deleted from every device you sync with.", ); if (!confirmed) return; await repo.notes.deleteForever(id); const idx = items.value.findIndex((n) => n.id === id); if (idx >= 0) items.value.splice(idx, 1); } async function fetchRevisions(id: string): Promise { return await repo.notes.revisions(id); } async function restoreRevision(id: string, revId: string): Promise { const note = await repo.notes.restoreRevision(id, revId); reconcile(note); return note; } return { items, loading, view, activeLabel, activeFacets, load, create, setPinned, setArchived, setReminder, setRecurrence, completeReminder, snoozeReminder, saveEdit, setLabels, addItem, updateItem, deleteItem, uploadAttachment, deleteAttachment, unfurl, deletePreview, importNotes, fetchOne, reorder, trash, restore, deleteForever, fetchRevisions, restoreRevision, }; });