## Projects & Milestones (Phases A + G) - New models: Project, Milestone (Project → Milestone → Task hierarchy) - notes table: project_id + milestone_id FKs; parent_id FK constraint activated - Migrations: 0017 (projects), 0018 (push_subscriptions), 0019 (events), 0020 (milestones) - Services: projects.py, milestones.py (CRUD + progress tracking) - Routes: /api/projects + /api/projects/<id>/milestones - LLM tools: create/list/get/update project; create/list milestone; project + milestone + parent_task params on note/task tools - Frontend: ProjectListView (stacked milestone bars), ProjectView (milestone-grouped kanban), ProjectSelector, MilestoneSelector, NoteEditorView + TaskEditorView updated ## RAG Auto-injection (Phase B) - Notes ≥0.60 cosine similarity auto-injected into system prompt (max 3, 800 chars each) - excluded_note_ids param; ChatView "Auto-included" sidebar section ## Summarisation improvements (Phase C) - Threshold 20→30, keep-recent 6→8, max_tokens 200→400 - Two-pass summarisation for histories >50 messages ## Browser push notifications (Phase E) - PushSubscription model + migration; pywebpush dependency - /api/push routes; VAPID config; fire-and-forget on generation complete - Frontend: sw.js, push store, Settings toggle ## PWA manifest (Phase F) - manifest.json, Apple meta tags, service worker registration in main.ts ## Tag normalisation - All tags lowercased + deduplicated at backend (create_note/update_note) and frontend (TagInput sanitize) - Note/Task types gain project_id + milestone_id fields; store signatures updated ## CalDAV - Radicale embedded server reverted; back to user-configured external CalDAV Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
246 lines
6.2 KiB
TypeScript
246 lines
6.2 KiB
TypeScript
import { ref } from "vue";
|
|
import { defineStore } from "pinia";
|
|
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
|
|
import { useToastStore } from "@/stores/toast";
|
|
import type { Note, NoteListResponse } from "@/types/note";
|
|
|
|
export const useNotesStore = defineStore("notes", () => {
|
|
const notes = ref<Note[]>([]);
|
|
const currentNote = ref<Note | null>(null);
|
|
const total = ref(0);
|
|
const loading = ref(false);
|
|
|
|
// Filter / pagination / sort state
|
|
const activeTagFilters = ref<string[]>([]);
|
|
const limit = ref(20);
|
|
const offset = ref(0);
|
|
const sortField = ref("updated_at");
|
|
const sortOrder = ref<"asc" | "desc">("desc");
|
|
const searchQuery = ref("");
|
|
|
|
async function refresh() {
|
|
loading.value = true;
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
if (searchQuery.value) searchParams.set("q", searchQuery.value);
|
|
for (const t of activeTagFilters.value) {
|
|
searchParams.append("tag", t);
|
|
}
|
|
searchParams.set("sort", sortField.value);
|
|
searchParams.set("order", sortOrder.value);
|
|
searchParams.set("limit", String(limit.value));
|
|
searchParams.set("offset", String(offset.value));
|
|
|
|
const qs = searchParams.toString();
|
|
const data = await apiGet<NoteListResponse>(
|
|
`/api/notes${qs ? `?${qs}` : ""}`
|
|
);
|
|
notes.value = data.notes;
|
|
total.value = data.total;
|
|
} catch (e) {
|
|
useToastStore().show("Failed to load notes", "error");
|
|
throw e;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function fetchNotes(params?: {
|
|
q?: string;
|
|
tag?: string[];
|
|
sort?: string;
|
|
order?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}) {
|
|
if (params?.q !== undefined) searchQuery.value = params.q || "";
|
|
if (params?.tag) activeTagFilters.value = params.tag;
|
|
if (params?.sort) sortField.value = params.sort;
|
|
if (params?.order) sortOrder.value = params.order as "asc" | "desc";
|
|
if (params?.limit) limit.value = params.limit;
|
|
if (params?.offset !== undefined) offset.value = params.offset;
|
|
await refresh();
|
|
}
|
|
|
|
async function fetchNote(id: number) {
|
|
loading.value = true;
|
|
try {
|
|
currentNote.value = await apiGet<Note>(`/api/notes/${id}`);
|
|
} catch (e) {
|
|
useToastStore().show("Failed to load note", "error");
|
|
throw e;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function createNote(data: {
|
|
title: string;
|
|
body: string;
|
|
tags?: string[];
|
|
project_id?: number | null;
|
|
}): Promise<Note> {
|
|
try {
|
|
return await apiPost<Note>("/api/notes", data);
|
|
} catch (e) {
|
|
useToastStore().show("Failed to create note", "error");
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function updateNote(
|
|
id: number,
|
|
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id">>
|
|
): Promise<Note> {
|
|
try {
|
|
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
|
if (currentNote.value?.id === id) {
|
|
currentNote.value = note;
|
|
}
|
|
return note;
|
|
} catch (e) {
|
|
useToastStore().show("Failed to update note", "error");
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function deleteNote(id: number) {
|
|
try {
|
|
await apiDelete(`/api/notes/${id}`);
|
|
notes.value = notes.value.filter((n) => n.id !== id);
|
|
if (currentNote.value?.id === id) {
|
|
currentNote.value = null;
|
|
}
|
|
} catch (e) {
|
|
useToastStore().show("Failed to delete note", "error");
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function addTagFilter(tag: string) {
|
|
if (!activeTagFilters.value.includes(tag)) {
|
|
activeTagFilters.value.push(tag);
|
|
offset.value = 0;
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
function removeTagFilter(tag: string) {
|
|
activeTagFilters.value = activeTagFilters.value.filter((t) => t !== tag);
|
|
offset.value = 0;
|
|
refresh();
|
|
}
|
|
|
|
function clearTagFilters() {
|
|
activeTagFilters.value = [];
|
|
offset.value = 0;
|
|
refresh();
|
|
}
|
|
|
|
function setTagFilters(tags: string[]) {
|
|
activeTagFilters.value = [...tags];
|
|
offset.value = 0;
|
|
refresh();
|
|
}
|
|
|
|
function setSort(field: string, order: "asc" | "desc") {
|
|
sortField.value = field;
|
|
sortOrder.value = order;
|
|
offset.value = 0;
|
|
refresh();
|
|
}
|
|
|
|
function setOffset(newOffset: number) {
|
|
offset.value = newOffset;
|
|
refresh();
|
|
}
|
|
|
|
function setSearch(q: string) {
|
|
searchQuery.value = q;
|
|
offset.value = 0;
|
|
refresh();
|
|
}
|
|
|
|
async function resolveTitle(title: string): Promise<Note> {
|
|
return await apiPost<Note>("/api/notes/resolve-title", { title });
|
|
}
|
|
|
|
async function convertToTask(noteId: number): Promise<Note> {
|
|
try {
|
|
const note = await apiPost<Note>(
|
|
`/api/notes/${noteId}/convert-to-task`,
|
|
{}
|
|
);
|
|
if (currentNote.value?.id === noteId) {
|
|
currentNote.value = note;
|
|
}
|
|
return note;
|
|
} catch (e) {
|
|
useToastStore().show("Failed to convert to task", "error");
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function convertToNote(noteId: number): Promise<Note> {
|
|
try {
|
|
const note = await apiPost<Note>(
|
|
`/api/notes/${noteId}/convert-to-note`,
|
|
{}
|
|
);
|
|
if (currentNote.value?.id === noteId) {
|
|
currentNote.value = note;
|
|
}
|
|
return note;
|
|
} catch (e) {
|
|
useToastStore().show("Failed to convert to note", "error");
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function fetchBacklinks(
|
|
noteId: number
|
|
): Promise<{ type: string; id: number; title: string }[]> {
|
|
const data = await apiGet<{
|
|
backlinks: { type: string; id: number; title: string }[];
|
|
}>(`/api/notes/${noteId}/backlinks`);
|
|
return data.backlinks;
|
|
}
|
|
|
|
async function fetchAllTags(q?: string): Promise<string[]> {
|
|
const params = q ? `?q=${encodeURIComponent(q)}` : "";
|
|
const data = await apiGet<{ tags: string[] }>(`/api/notes/tags${params}`);
|
|
return data.tags;
|
|
}
|
|
|
|
return {
|
|
notes,
|
|
currentNote,
|
|
total,
|
|
loading,
|
|
activeTagFilters,
|
|
limit,
|
|
offset,
|
|
sortField,
|
|
sortOrder,
|
|
searchQuery,
|
|
fetchNotes,
|
|
fetchNote,
|
|
createNote,
|
|
updateNote,
|
|
deleteNote,
|
|
addTagFilter,
|
|
removeTagFilter,
|
|
clearTagFilters,
|
|
setTagFilters,
|
|
setSort,
|
|
setOffset,
|
|
setSearch,
|
|
refresh,
|
|
resolveTitle,
|
|
convertToTask,
|
|
convertToNote,
|
|
fetchBacklinks,
|
|
fetchAllTags,
|
|
};
|
|
});
|