Add Projects, Milestones, RAG auto-inject, push notifications, PWA, tag normalisation
## 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>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
"""Add projects table and project_id to notes."""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0017"
|
||||
down_revision = "0016"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
goal TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
color TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_projects_user_id ON projects(user_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_projects_status ON projects(status)")
|
||||
op.execute("ALTER TABLE notes ADD COLUMN IF NOT EXISTS project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_notes_project_id ON notes(project_id)")
|
||||
# Add FK constraint to existing parent_id column (was nullable with no FK)
|
||||
# Only add if constraint doesn't already exist
|
||||
op.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'notes_parent_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE notes ADD CONSTRAINT notes_parent_id_fkey
|
||||
FOREIGN KEY (parent_id) REFERENCES notes(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE notes DROP CONSTRAINT IF EXISTS notes_parent_id_fkey")
|
||||
op.execute("DROP INDEX IF EXISTS ix_notes_project_id")
|
||||
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS project_id")
|
||||
op.execute("DROP INDEX IF EXISTS ix_projects_status")
|
||||
op.execute("DROP INDEX IF EXISTS ix_projects_user_id")
|
||||
op.execute("DROP TABLE IF EXISTS projects")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add push_subscriptions table for web push notifications."""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0018"
|
||||
down_revision = "0017"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
last_used TIMESTAMP WITH TIME ZONE,
|
||||
UNIQUE(user_id, endpoint)
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_push_subscriptions_user_id ON push_subscriptions(user_id)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_push_subscriptions_user_id")
|
||||
op.execute("DROP TABLE IF EXISTS push_subscriptions")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Add events table for internal CalDAV (Radicale) linkage."""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0019"
|
||||
down_revision = "0018"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
|
||||
uid TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
start_dt TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
end_dt TIMESTAMP WITH TIME ZONE,
|
||||
all_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
recurrence TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(user_id, uid)
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_events_user_id ON events(user_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_events_project_id ON events(project_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_events_start_dt ON events(start_dt)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_events_start_dt")
|
||||
op.execute("DROP INDEX IF EXISTS ix_events_project_id")
|
||||
op.execute("DROP INDEX IF EXISTS ix_events_user_id")
|
||||
op.execute("DROP TABLE IF EXISTS events")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Add milestones table and milestone_id to notes."""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0020"
|
||||
down_revision = "0019"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS milestones (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
order_index INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_milestones_user_id ON milestones(user_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_milestones_project_id ON milestones(project_id)")
|
||||
|
||||
op.execute("""
|
||||
ALTER TABLE notes
|
||||
ADD COLUMN IF NOT EXISTS milestone_id INTEGER REFERENCES milestones(id) ON DELETE SET NULL
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_notes_milestone_id ON notes(milestone_id)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_notes_milestone_id")
|
||||
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS milestone_id")
|
||||
op.execute("DROP INDEX IF EXISTS ix_milestones_project_id")
|
||||
op.execute("DROP INDEX IF EXISTS ix_milestones_user_id")
|
||||
op.execute("DROP TABLE IF EXISTS milestones")
|
||||
@@ -21,6 +21,10 @@ services:
|
||||
# SEARXNG_URL: "http://searxng:8080"
|
||||
# IMAGE_CACHE_DIR: /data/images # default, change if using a different mount path
|
||||
# IMAGE_MAX_BYTES: "5242880" # 5 MB per image, adjust if needed
|
||||
# Push notifications (VAPID keys - generate with: python -c "from py_vapid import Vapid01; v=Vapid01(); v.generate_keys(); print(v.private_key, v.public_key)")
|
||||
VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}"
|
||||
VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}"
|
||||
VAPID_CLAIMS_SUB: "${VAPID_CLAIMS_SUB:-mailto:admin@fabledassistant.local}"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
||||
interval: 10s
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#6c63ff">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Fabled">
|
||||
<title>Fabled Assistant</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Fabled Assistant",
|
||||
"short_name": "Fabled",
|
||||
"description": "Your self-hosted second brain with AI assistance",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a2e",
|
||||
"theme_color": "#6c63ff",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.ico",
|
||||
"sizes": "any",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Service Worker for push notifications and PWA support
|
||||
self.addEventListener('push', event => {
|
||||
if (!event.data) return;
|
||||
const data = event.data.json();
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'Fabled Assistant', {
|
||||
body: data.body || '',
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
data: { url: data.url || '/' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
|
||||
const url = event.notification.data.url;
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(url) && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(url);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// PWA: serve cached assets for offline support (minimal)
|
||||
self.addEventListener('fetch', event => {
|
||||
// Pass-through — no offline caching in v1
|
||||
});
|
||||
@@ -73,6 +73,7 @@ router.afterEach(() => {
|
||||
|
||||
<div class="nav-links" :class="{ open: mobileMenuOpen }">
|
||||
<router-link to="/notes" class="nav-link">Notes</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/chat" class="nav-link">Chat</router-link>
|
||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
|
||||
interface Milestone {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number | null;
|
||||
modelValue: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", val: number | null): void;
|
||||
}>();
|
||||
|
||||
const milestones = ref<Milestone[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.projectId,
|
||||
async (pid) => {
|
||||
milestones.value = [];
|
||||
if (!pid) {
|
||||
emit("update:modelValue", null);
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await apiGet<{ milestones: Milestone[] }>(
|
||||
`/api/projects/${pid}/milestones`
|
||||
);
|
||||
milestones.value = data.milestones.filter((m) => m.status === "active");
|
||||
} catch {
|
||||
milestones.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function onChange(e: Event) {
|
||||
const val = (e.target as HTMLSelectElement).value;
|
||||
emit("update:modelValue", val ? Number(val) : null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select
|
||||
class="milestone-select"
|
||||
:value="modelValue ?? ''"
|
||||
:disabled="!projectId || loading"
|
||||
@change="onChange"
|
||||
>
|
||||
<option value="">{{ projectId ? "No milestone" : "(Select project first)" }}</option>
|
||||
<option v-for="m in milestones" :key="m.id" :value="m.id">
|
||||
{{ m.title }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.milestone-select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.milestone-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.milestone-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
modelValue: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: number | null): void;
|
||||
}>();
|
||||
|
||||
const projects = ref<Project[]>([]);
|
||||
let cacheExpiry = 0;
|
||||
|
||||
async function loadProjects() {
|
||||
const now = Date.now();
|
||||
if (projects.value.length > 0 && now < cacheExpiry) return;
|
||||
try {
|
||||
const data = await apiGet<{ projects: Project[] }>("/api/projects?status=active");
|
||||
projects.value = data.projects;
|
||||
cacheExpiry = now + 60_000; // 60 second cache
|
||||
} catch {
|
||||
// Silently fail — selector will just be empty
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadProjects);
|
||||
|
||||
function onChange(e: Event) {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
emit("update:modelValue", value ? Number(value) : null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select class="project-selector" :value="modelValue ?? ''" @change="onChange">
|
||||
<option value="">No project</option>
|
||||
<option v-for="project in projects" :key="project.id" :value="project.id">
|
||||
{{ project.title }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.project-selector {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.project-selector:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -18,7 +18,7 @@ const inputRef = ref<HTMLInputElement | null>(null);
|
||||
let fetchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function sanitize(raw: string): string {
|
||||
return raw.trim().replace(/\s+/g, "-").replace(/^#+/, "");
|
||||
return raw.trim().toLowerCase().replace(/\s+/g, "-").replace(/^#+/, "");
|
||||
}
|
||||
|
||||
function addTag(raw: string) {
|
||||
|
||||
@@ -9,3 +9,12 @@ const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||
|
||||
// Register service worker for push notifications and PWA
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
// SW registration failed — app still works, just no push notifications
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,6 +60,16 @@ const router = createRouter({
|
||||
name: "note-edit",
|
||||
component: () => import("@/views/NoteEditorView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/projects",
|
||||
name: "projects",
|
||||
component: () => import("@/views/ProjectListView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/projects/:id",
|
||||
name: "project-view",
|
||||
component: () => import("@/views/ProjectView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/tasks",
|
||||
name: "tasks",
|
||||
|
||||
@@ -123,6 +123,7 @@ export const useChatStore = defineStore("chat", () => {
|
||||
includeNoteIds?: number[],
|
||||
think = false,
|
||||
contextNoteTitle?: string,
|
||||
excludeNoteIds?: number[],
|
||||
) {
|
||||
if (!currentConversation.value) return;
|
||||
|
||||
@@ -169,6 +170,7 @@ export const useChatStore = defineStore("chat", () => {
|
||||
content,
|
||||
context_note_id: contextNoteId,
|
||||
include_note_ids: includeNoteIds?.length ? includeNoteIds : undefined,
|
||||
excluded_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined,
|
||||
think,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -78,6 +78,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
title: string;
|
||||
body: string;
|
||||
tags?: string[];
|
||||
project_id?: number | null;
|
||||
}): Promise<Note> {
|
||||
try {
|
||||
return await apiPost<Note>("/api/notes", data);
|
||||
@@ -89,7 +90,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
|
||||
async function updateNote(
|
||||
id: number,
|
||||
data: Partial<Pick<Note, "title" | "body" | "tags">>
|
||||
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id">>
|
||||
): Promise<Note> {
|
||||
try {
|
||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = window.atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
return outputArray
|
||||
}
|
||||
|
||||
export const usePushStore = defineStore('push', () => {
|
||||
const isSupported = ref(
|
||||
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
|
||||
)
|
||||
const permission = ref<NotificationPermission>(
|
||||
'Notification' in window ? Notification.permission : 'denied'
|
||||
)
|
||||
const isSubscribed = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function checkSubscription(): Promise<void> {
|
||||
if (!isSupported.value) return
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
const sub = await reg.pushManager.getSubscription()
|
||||
isSubscribed.value = !!sub
|
||||
} catch {
|
||||
isSubscribed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe(): Promise<void> {
|
||||
if (!isSupported.value) {
|
||||
error.value = 'Push notifications not supported in this browser'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
// Request permission
|
||||
permission.value = await Notification.requestPermission()
|
||||
if (permission.value !== 'granted') {
|
||||
error.value = 'Notification permission denied'
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch VAPID public key
|
||||
const resp = await fetch('/api/push/vapid-public-key')
|
||||
if (!resp.ok) {
|
||||
error.value = 'Push notifications not configured on server'
|
||||
return
|
||||
}
|
||||
const { publicKey } = await resp.json()
|
||||
|
||||
// Register service worker
|
||||
const reg = await navigator.serviceWorker.register('/sw.js')
|
||||
await navigator.serviceWorker.ready
|
||||
|
||||
// Subscribe
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
})
|
||||
|
||||
// Save to server
|
||||
const saveResp = await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
})
|
||||
if (!saveResp.ok) throw new Error('Failed to save subscription')
|
||||
isSubscribed.value = true
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to subscribe'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribe(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
const sub = await reg.pushManager.getSubscription()
|
||||
if (sub) {
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||
})
|
||||
await sub.unsubscribe()
|
||||
}
|
||||
isSubscribed.value = false
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to unsubscribe'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { isSupported, permission, isSubscribed, loading, error, checkSubscription, subscribe, unsubscribe }
|
||||
})
|
||||
@@ -69,6 +69,9 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
status?: TaskStatus;
|
||||
priority?: TaskPriority;
|
||||
due_date?: string | null;
|
||||
project_id?: number | null;
|
||||
milestone_id?: number | null;
|
||||
parent_id?: number | null;
|
||||
}): Promise<Task> {
|
||||
try {
|
||||
return await apiPost<Task>("/api/tasks", data);
|
||||
@@ -81,7 +84,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
async function updateTask(
|
||||
id: number,
|
||||
data: Partial<
|
||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date">
|
||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id">
|
||||
>
|
||||
): Promise<Task> {
|
||||
try {
|
||||
|
||||
@@ -54,7 +54,8 @@ export interface ConversationDetail extends Conversation {
|
||||
export interface ContextMeta {
|
||||
context_note_id: number | null;
|
||||
context_note_title: string | null;
|
||||
auto_notes: { id: number; title: string; score?: number | null }[];
|
||||
auto_notes: { id: number; title: string; score?: number | null; auto_injected?: boolean }[];
|
||||
auto_injected_notes?: { id: number; title: string; score?: number | null }[];
|
||||
}
|
||||
|
||||
export interface OllamaStatus {
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface Note {
|
||||
body: string;
|
||||
tags: string[];
|
||||
parent_id: number | null;
|
||||
project_id: number | null;
|
||||
milestone_id: number | null;
|
||||
status: TaskStatus | null;
|
||||
priority: TaskPriority | null;
|
||||
due_date: string | null;
|
||||
|
||||
@@ -37,9 +37,15 @@ let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const includedNoteIds = ref<Set<number>>(new Set());
|
||||
const includedNotes = ref<{ id: number; title: string }[]>([]);
|
||||
|
||||
// Suggested notes — auto-found by search, not yet included
|
||||
// Suggested notes — auto-found by search, not yet included (score 0.45–0.59)
|
||||
const suggestedNotes = ref<{ id: number; title: string; score?: number | null }[]>([]);
|
||||
|
||||
// Auto-injected notes — injected automatically because score >= 0.60
|
||||
const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null }[]>([]);
|
||||
|
||||
// Note IDs excluded from auto-injection on next message
|
||||
const excludedNoteIds = ref<number[]>([]);
|
||||
|
||||
let prevConvId: number | null = null;
|
||||
|
||||
const convId = computed(() => {
|
||||
@@ -98,6 +104,8 @@ watch(convId, async (newId) => {
|
||||
includedNoteIds.value = new Set();
|
||||
includedNotes.value = [];
|
||||
suggestedNotes.value = [];
|
||||
autoInjectedNotes.value = [];
|
||||
excludedNoteIds.value = [];
|
||||
store.lastContextMeta = null;
|
||||
if (newId) {
|
||||
// Skip re-fetch if this conversation is already loaded (avoids race
|
||||
@@ -118,14 +126,35 @@ watch(
|
||||
);
|
||||
|
||||
watch(
|
||||
() => store.lastContextMeta?.auto_notes,
|
||||
(newNotes) => {
|
||||
if (!newNotes) return;
|
||||
() => store.lastContextMeta,
|
||||
(meta) => {
|
||||
if (!meta) return;
|
||||
|
||||
const alreadyIncluded = includedNoteIds.value;
|
||||
const alreadyAutoInjected = new Set(autoInjectedNotes.value.map((n) => n.id));
|
||||
const alreadySuggested = new Set(suggestedNotes.value.map((n) => n.id));
|
||||
for (const note of newNotes) {
|
||||
if (!alreadyIncluded.has(note.id) && !alreadySuggested.has(note.id)) {
|
||||
suggestedNotes.value.push(note);
|
||||
|
||||
// Process auto_injected_notes (explicitly marked as injected)
|
||||
const injectedFromMeta = meta.auto_injected_notes ?? [];
|
||||
for (const note of injectedFromMeta) {
|
||||
if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
|
||||
autoInjectedNotes.value.push(note);
|
||||
alreadyAutoInjected.add(note.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Process auto_notes — auto_injected flag separates injected from suggested
|
||||
const autoNotes = meta.auto_notes ?? [];
|
||||
for (const note of autoNotes) {
|
||||
if (note.auto_injected) {
|
||||
if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
|
||||
autoInjectedNotes.value.push(note);
|
||||
alreadyAutoInjected.add(note.id);
|
||||
}
|
||||
} else {
|
||||
if (!alreadyIncluded.has(note.id) && !alreadySuggested.has(note.id) && !alreadyAutoInjected.has(note.id)) {
|
||||
suggestedNotes.value.push(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,6 +211,8 @@ async function sendMessage() {
|
||||
undefined,
|
||||
includedNoteIds.value.size ? [...includedNoteIds.value] : undefined,
|
||||
true, // enable thinking in the full chat view
|
||||
undefined,
|
||||
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
|
||||
);
|
||||
sending.value = false;
|
||||
|
||||
@@ -303,6 +334,14 @@ function includeNote(note: { id: number; title: string }) {
|
||||
includedNoteIds.value = new Set([...includedNoteIds.value, note.id]);
|
||||
includedNotes.value.push(note);
|
||||
suggestedNotes.value = suggestedNotes.value.filter((n) => n.id !== note.id);
|
||||
autoInjectedNotes.value = autoInjectedNotes.value.filter((n) => n.id !== note.id);
|
||||
}
|
||||
|
||||
function excludeAutoNote(noteId: number) {
|
||||
autoInjectedNotes.value = autoInjectedNotes.value.filter((n) => n.id !== noteId);
|
||||
if (!excludedNoteIds.value.includes(noteId)) {
|
||||
excludedNoteIds.value = [...excludedNoteIds.value, noteId];
|
||||
}
|
||||
}
|
||||
|
||||
function removeIncludedNote(noteId: number) {
|
||||
@@ -461,23 +500,31 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- Context sidebar -->
|
||||
<aside v-if="includedNotes.length || suggestedNotes.length" class="context-sidebar">
|
||||
<!-- IN CONTEXT section -->
|
||||
<template v-if="includedNotes.length">
|
||||
<div class="context-sidebar-header">In Context</div>
|
||||
|
||||
<!-- Explicitly included notes -->
|
||||
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
|
||||
<aside v-if="autoInjectedNotes.length || includedNotes.length || suggestedNotes.length" class="context-sidebar">
|
||||
<!-- AUTO-INCLUDED section -->
|
||||
<template v-if="autoInjectedNotes.length">
|
||||
<div class="context-sidebar-header">Auto-included</div>
|
||||
<div v-for="note in autoInjectedNotes" :key="note.id" class="context-note context-note-auto">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">
|
||||
{{ note.title }}
|
||||
</router-link>
|
||||
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context">×</button>
|
||||
<span
|
||||
v-if="note.score != null"
|
||||
class="context-note-score"
|
||||
:class="{
|
||||
'score-high': note.score >= 0.75,
|
||||
'score-medium': note.score >= 0.60 && note.score < 0.75,
|
||||
'score-low': note.score < 0.60,
|
||||
}"
|
||||
:title="`Relevance score: ${note.score}`"
|
||||
>{{ Math.round(note.score * 100) }}%</span>
|
||||
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Exclude from auto-injection">×</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- SUGGESTED section -->
|
||||
<template v-if="suggestedNotes.length">
|
||||
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': includedNotes.length }">Suggested</div>
|
||||
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length }">Suggested</div>
|
||||
<div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">
|
||||
{{ note.title }}
|
||||
@@ -495,6 +542,18 @@ onUnmounted(() => {
|
||||
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- IN CONTEXT section -->
|
||||
<template v-if="includedNotes.length">
|
||||
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }">In Context</div>
|
||||
<!-- Explicitly included notes -->
|
||||
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">
|
||||
{{ note.title }}
|
||||
</router-link>
|
||||
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context">×</button>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -814,6 +873,9 @@ onUnmounted(() => {
|
||||
.context-note-included {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.context-note-auto {
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
.context-note-suggested {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Editor } from "@tiptap/vue-3";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -19,6 +20,7 @@ const toast = useToastStore();
|
||||
const title = ref("");
|
||||
const body = ref("");
|
||||
const tags = ref<string[]>([]);
|
||||
const projectId = ref<number | null>(null);
|
||||
const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
const showPreview = ref(false);
|
||||
@@ -136,12 +138,14 @@ function dismissTagSuggestions() {
|
||||
let savedTitle = "";
|
||||
let savedBody = "";
|
||||
let savedTags: string[] = [];
|
||||
let savedProjectId: number | null = null;
|
||||
|
||||
function markDirty() {
|
||||
dirty.value =
|
||||
title.value !== savedTitle ||
|
||||
body.value !== savedBody ||
|
||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags);
|
||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||
projectId.value !== savedProjectId;
|
||||
}
|
||||
|
||||
function onBodyUpdate(newVal: string) {
|
||||
@@ -156,9 +160,11 @@ onMounted(async () => {
|
||||
title.value = store.currentNote.title;
|
||||
body.value = store.currentNote.body;
|
||||
tags.value = [...(store.currentNote.tags || [])];
|
||||
projectId.value = store.currentNote.project_id ?? null;
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedProjectId = projectId.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -172,10 +178,12 @@ async function save() {
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
tags: tags.value,
|
||||
project_id: projectId.value,
|
||||
});
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedProjectId = projectId.value;
|
||||
dirty.value = false;
|
||||
toast.show("Note saved");
|
||||
} else {
|
||||
@@ -183,6 +191,7 @@ async function save() {
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
tags: tags.value,
|
||||
project_id: projectId.value,
|
||||
});
|
||||
dirty.value = false;
|
||||
toast.show("Note created");
|
||||
@@ -223,10 +232,11 @@ async function autoSave() {
|
||||
if (!isEditing.value || !dirty.value || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await store.updateNote(noteId.value!, { title: title.value, body: body.value, tags: tags.value });
|
||||
await store.updateNote(noteId.value!, { title: title.value, body: body.value, tags: tags.value, project_id: projectId.value } as Record<string, unknown>);
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedProjectId = projectId.value;
|
||||
dirty.value = false;
|
||||
toast.show("Auto-saved");
|
||||
} catch {
|
||||
@@ -290,6 +300,13 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
@input="markDirty"
|
||||
/>
|
||||
|
||||
<div class="field-row-meta">
|
||||
<div class="meta-field">
|
||||
<label class="meta-label">Project</label>
|
||||
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TagInput
|
||||
v-model="tags"
|
||||
:fetchTags="(q: string) => store.fetchAllTags(q)"
|
||||
@@ -480,4 +497,25 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/editor-shared.css" />
|
||||
<style src="@/assets/editor-shared.css" />
|
||||
<style scoped>
|
||||
.field-row-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.meta-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 180px;
|
||||
flex: 1;
|
||||
max-width: 300px;
|
||||
}
|
||||
.meta-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,581 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiGet, apiPost } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
interface MilestoneSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
pct: number;
|
||||
total: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
goal: string | null;
|
||||
status: "active" | "completed" | "archived";
|
||||
color: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
summary?: {
|
||||
task_counts: { todo?: number; in_progress?: number; done?: number };
|
||||
note_count: number;
|
||||
milestone_summary: MilestoneSummary[];
|
||||
};
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToastStore();
|
||||
|
||||
const projects = ref<Project[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const activeTab = ref<"all" | "active" | "completed" | "archived">("all");
|
||||
|
||||
// New project modal
|
||||
const showNewProjectModal = ref(false);
|
||||
const newTitle = ref("");
|
||||
const newDescription = ref("");
|
||||
const newGoal = ref("");
|
||||
const creating = ref(false);
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
if (activeTab.value === "all") return projects.value;
|
||||
return projects.value.filter((p) => p.status === activeTab.value);
|
||||
});
|
||||
|
||||
async function loadProjects() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const data = await apiGet<{ projects: Project[] }>("/api/projects");
|
||||
projects.value = data.projects;
|
||||
// Fetch summaries (including milestone_summary) in parallel
|
||||
await Promise.allSettled(
|
||||
projects.value.map(async (p) => {
|
||||
try {
|
||||
const full = await apiGet<Project>(`/api/projects/${p.id}`);
|
||||
p.summary = full.summary;
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
error.value = "Failed to load projects.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function milestoneColor(index: number): string {
|
||||
const palette = [
|
||||
"var(--color-primary)",
|
||||
"var(--color-success)",
|
||||
"#c98a00",
|
||||
"#8b5cf6",
|
||||
"#ef4444",
|
||||
"#06b6d4",
|
||||
];
|
||||
return palette[index % palette.length];
|
||||
}
|
||||
|
||||
onMounted(loadProjects);
|
||||
|
||||
function openNewProjectModal() {
|
||||
newTitle.value = "";
|
||||
newDescription.value = "";
|
||||
newGoal.value = "";
|
||||
showNewProjectModal.value = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showNewProjectModal.value = false;
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
if (!newTitle.value.trim()) return;
|
||||
creating.value = true;
|
||||
try {
|
||||
const project = await apiPost<Project>("/api/projects", {
|
||||
title: newTitle.value.trim(),
|
||||
description: newDescription.value.trim() || undefined,
|
||||
goal: newGoal.value.trim() || undefined,
|
||||
});
|
||||
projects.value.unshift(project);
|
||||
showNewProjectModal.value = false;
|
||||
toast.show("Project created");
|
||||
router.push(`/projects/${project.id}`);
|
||||
} catch {
|
||||
toast.show("Failed to create project", "error");
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: Project["status"]): string {
|
||||
if (status === "active") return "Active";
|
||||
if (status === "completed") return "Completed";
|
||||
if (status === "archived") return "Archived";
|
||||
return status;
|
||||
}
|
||||
|
||||
function truncate(text: string | null, max = 120): string {
|
||||
if (!text) return "";
|
||||
return text.length > max ? text.slice(0, max) + "..." : text;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="projects-list">
|
||||
<div class="page-header">
|
||||
<h1>Projects</h1>
|
||||
<button class="btn-primary" @click="openNewProjectModal">+ New Project</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter tabs -->
|
||||
<div class="filter-tabs">
|
||||
<button
|
||||
v-for="tab in ['all', 'active', 'completed', 'archived'] as const"
|
||||
:key="tab"
|
||||
:class="['tab-btn', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
>
|
||||
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="loading-msg">Loading...</p>
|
||||
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||||
|
||||
<div v-else-if="filteredProjects.length === 0" class="empty-state">
|
||||
<p class="empty-title">No projects yet</p>
|
||||
<p class="empty-subtitle">Create your first project to organize tasks and notes.</p>
|
||||
<button class="btn-cta" @click="openNewProjectModal">+ New Project</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="projects-grid">
|
||||
<div
|
||||
v-for="project in filteredProjects"
|
||||
:key="project.id"
|
||||
class="project-card"
|
||||
@click="router.push(`/projects/${project.id}`)"
|
||||
>
|
||||
<div class="card-header">
|
||||
<span class="project-title">{{ project.title }}</span>
|
||||
<span
|
||||
:class="['status-badge', `status-${project.status}`]"
|
||||
>{{ statusLabel(project.status) }}</span>
|
||||
</div>
|
||||
<p v-if="project.goal" class="project-goal">
|
||||
<span class="field-label">Goal:</span> {{ truncate(project.goal) }}
|
||||
</p>
|
||||
<p v-if="project.description" class="project-desc">{{ truncate(project.description) }}</p>
|
||||
|
||||
<!-- Milestone progress bars -->
|
||||
<div
|
||||
v-if="project.summary?.milestone_summary?.length"
|
||||
class="milestone-bars"
|
||||
>
|
||||
<div
|
||||
v-for="(ms, i) in project.summary.milestone_summary"
|
||||
:key="ms.id"
|
||||
class="milestone-bar-row"
|
||||
:title="`${ms.title} — ${ms.pct}% (${ms.completed}/${ms.total} tasks)`"
|
||||
>
|
||||
<span class="milestone-bar-label">{{ ms.title }}</span>
|
||||
<div class="milestone-bar-track">
|
||||
<div
|
||||
class="milestone-bar-fill"
|
||||
:style="{ width: ms.pct + '%', background: milestoneColor(i) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="milestone-bar-pct">{{ ms.pct }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<span class="meta-date">Updated {{ new Date(project.updated_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Project Modal -->
|
||||
<teleport to="body">
|
||||
<div v-if="showNewProjectModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal-card">
|
||||
<h3 class="modal-title">New Project</h3>
|
||||
<div class="modal-field">
|
||||
<label>Title <span class="required">*</span></label>
|
||||
<input
|
||||
v-model="newTitle"
|
||||
type="text"
|
||||
class="modal-input"
|
||||
placeholder="Project title"
|
||||
autofocus
|
||||
@keydown.enter="createProject"
|
||||
@keydown.escape="closeModal"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Goal</label>
|
||||
<input
|
||||
v-model="newGoal"
|
||||
type="text"
|
||||
class="modal-input"
|
||||
placeholder="What are you trying to achieve?"
|
||||
@keydown.escape="closeModal"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
v-model="newDescription"
|
||||
class="modal-textarea"
|
||||
placeholder="Optional description..."
|
||||
rows="3"
|
||||
@keydown.escape="closeModal"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-btn" @click="closeModal">Cancel</button>
|
||||
<button
|
||||
class="modal-btn modal-btn-primary"
|
||||
@click="createProject"
|
||||
:disabled="!newTitle.trim() || creating"
|
||||
>
|
||||
{{ creating ? "Creating..." : "Create" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.projects-list {
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.tab-btn {
|
||||
padding: 0.4rem 0.85rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: inherit;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.tab-btn.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading-msg,
|
||||
.error-msg {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.empty-subtitle {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.btn-cta {
|
||||
display: inline-block;
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem 1.1rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.project-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.project-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-active {
|
||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
.status-completed {
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.status-archived {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.project-goal {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.field-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.project-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.milestone-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.milestone-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.milestone-bar-label {
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 0;
|
||||
flex: 0 0 30%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.milestone-bar-track {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: var(--color-border);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.milestone-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.milestone-bar-pct {
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
.meta-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-overlay, rgba(0,0,0,0.45));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 8px 32px var(--color-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.modal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.modal-field label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.required {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.modal-input,
|
||||
.modal-textarea {
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.modal-input:focus,
|
||||
.modal-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.modal-textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.modal-btn {
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-btn:hover {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.modal-btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.modal-btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.modal-btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.projects-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.modal-card {
|
||||
margin: 1rem;
|
||||
max-width: calc(100vw - 2rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,12 @@ import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { apiGet, apiPost, apiPut } from "@/api/client";
|
||||
import { usePushStore } from "@/stores/push";
|
||||
|
||||
const store = useSettingsStore();
|
||||
const authStore = useAuthStore();
|
||||
const toastStore = useToastStore();
|
||||
const pushStore = usePushStore();
|
||||
const assistantName = ref("");
|
||||
const defaultModel = ref("");
|
||||
const installedModels = ref<string[]>([]);
|
||||
@@ -73,6 +75,7 @@ const searchError = ref("");
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchSettings();
|
||||
pushStore.checkSubscription();
|
||||
assistantName.value = store.assistantName;
|
||||
newEmail.value = authStore.user?.email ?? "";
|
||||
|
||||
@@ -640,6 +643,59 @@ function hostname(url: string): string {
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- ── Push Notifications ── half width ── -->
|
||||
<section class="settings-section">
|
||||
<h2>Push Notifications</h2>
|
||||
<p class="section-desc">
|
||||
Receive browser push notifications when a response is ready.
|
||||
</p>
|
||||
<template v-if="!pushStore.isSupported">
|
||||
<p class="push-unsupported">Push notifications are not supported in this browser.</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="push-status-row">
|
||||
<span class="push-status-label">Permission:</span>
|
||||
<span
|
||||
:class="['push-permission-badge', {
|
||||
'perm-granted': pushStore.permission === 'granted',
|
||||
'perm-denied': pushStore.permission === 'denied',
|
||||
'perm-default': pushStore.permission === 'default',
|
||||
}]"
|
||||
>
|
||||
{{ pushStore.permission === 'granted' ? 'Granted' : pushStore.permission === 'denied' ? 'Denied' : 'Not asked' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="push-status-row">
|
||||
<span class="push-status-label">Status:</span>
|
||||
<span :class="['push-sub-badge', { 'sub-active': pushStore.isSubscribed }]">
|
||||
{{ pushStore.isSubscribed ? 'Subscribed' : 'Not subscribed' }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="pushStore.error" class="push-error">{{ pushStore.error }}</p>
|
||||
<div class="actions" style="margin-top: 0.75rem;">
|
||||
<button
|
||||
v-if="!pushStore.isSubscribed"
|
||||
class="btn-save"
|
||||
@click="pushStore.subscribe()"
|
||||
:disabled="pushStore.loading || pushStore.permission === 'denied'"
|
||||
>
|
||||
{{ pushStore.loading ? 'Enabling...' : 'Enable Notifications' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-secondary"
|
||||
@click="pushStore.unsubscribe()"
|
||||
:disabled="pushStore.loading"
|
||||
>
|
||||
{{ pushStore.loading ? 'Disabling...' : 'Disable Notifications' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="pushStore.permission === 'denied'" class="field-hint" style="margin-top: 0.5rem;">
|
||||
Notifications are blocked. Allow them in your browser site settings to re-enable.
|
||||
</p>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- ── Application URL (admin) ── full width ── -->
|
||||
<section v-if="authStore.isAdmin" class="settings-section full-width">
|
||||
<h2>Application URL</h2>
|
||||
@@ -1022,6 +1078,43 @@ function hostname(url: string): string {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
/* Push notifications */
|
||||
.push-unsupported {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.push-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.push-status-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.push-permission-badge,
|
||||
.push-sub-badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.perm-granted { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
||||
.perm-denied { background: color-mix(in srgb, var(--color-danger, #e74c3c) 15%, transparent); color: var(--color-danger, #e74c3c); }
|
||||
.sub-active { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
||||
.push-error {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-danger, #e74c3c);
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
/* Responsive — collapse to 1 column on narrow screens */
|
||||
@media (max-width: 640px) {
|
||||
.settings-grid {
|
||||
|
||||
@@ -6,12 +6,14 @@ import { useNotesStore } from "@/stores/notes";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { useAssist } from "@/composables/useAssist";
|
||||
import { apiPost } from "@/api/client";
|
||||
import { apiPost, apiGet } from "@/api/client";
|
||||
import type { TaskStatus, TaskPriority } from "@/types/task";
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -25,6 +27,15 @@ const tags = ref<string[]>([]);
|
||||
const status = ref<TaskStatus>("todo");
|
||||
const priority = ref<TaskPriority>("none");
|
||||
const dueDate = ref("");
|
||||
const projectId = ref<number | null>(null);
|
||||
const milestoneId = ref<number | null>(null);
|
||||
const parentId = ref<number | null>(null);
|
||||
const parentTitle = ref("");
|
||||
const parentSearchQuery = ref("");
|
||||
const parentSearchResults = ref<{ id: number; title: string }[]>([]);
|
||||
const parentSearchLoading = ref(false);
|
||||
const showParentDropdown = ref(false);
|
||||
let parentSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
const showPreview = ref(false);
|
||||
@@ -144,6 +155,9 @@ let savedTags: string[] = [];
|
||||
let savedStatus: TaskStatus = "todo";
|
||||
let savedPriority: TaskPriority = "none";
|
||||
let savedDueDate = "";
|
||||
let savedProjectId: number | null = null;
|
||||
let savedMilestoneId: number | null = null;
|
||||
let savedParentId: number | null = null;
|
||||
|
||||
function markDirty() {
|
||||
dirty.value =
|
||||
@@ -152,7 +166,10 @@ function markDirty() {
|
||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||
status.value !== savedStatus ||
|
||||
priority.value !== savedPriority ||
|
||||
dueDate.value !== savedDueDate;
|
||||
dueDate.value !== savedDueDate ||
|
||||
projectId.value !== savedProjectId ||
|
||||
milestoneId.value !== savedMilestoneId ||
|
||||
parentId.value !== savedParentId;
|
||||
}
|
||||
|
||||
function onBodyUpdate(newVal: string) {
|
||||
@@ -160,6 +177,58 @@ function onBodyUpdate(newVal: string) {
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function onParentSearchInput() {
|
||||
if (parentSearchTimer) clearTimeout(parentSearchTimer);
|
||||
if (!parentSearchQuery.value.trim()) {
|
||||
parentSearchResults.value = [];
|
||||
showParentDropdown.value = false;
|
||||
return;
|
||||
}
|
||||
parentSearchTimer = setTimeout(async () => {
|
||||
const q = parentSearchQuery.value.trim();
|
||||
if (!q) return;
|
||||
parentSearchLoading.value = true;
|
||||
showParentDropdown.value = true;
|
||||
try {
|
||||
const data = await apiGet<{ notes: Array<{ id: number; title: string }> }>(
|
||||
`/api/notes?q=${encodeURIComponent(q)}&type=task&limit=8`
|
||||
);
|
||||
parentSearchResults.value = data.notes.filter((t) =>
|
||||
!taskId.value || t.id !== taskId.value
|
||||
);
|
||||
} catch {
|
||||
parentSearchResults.value = [];
|
||||
} finally {
|
||||
parentSearchLoading.value = false;
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function selectParentTask(task: { id: number; title: string }) {
|
||||
parentId.value = task.id;
|
||||
parentTitle.value = task.title;
|
||||
parentSearchQuery.value = task.title;
|
||||
showParentDropdown.value = false;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function onParentFocus() {
|
||||
if (parentSearchQuery.value) showParentDropdown.value = true;
|
||||
}
|
||||
|
||||
function hideParentDropdown() {
|
||||
setTimeout(() => { showParentDropdown.value = false; }, 200);
|
||||
}
|
||||
|
||||
function clearParentTask() {
|
||||
parentId.value = null;
|
||||
parentTitle.value = "";
|
||||
parentSearchQuery.value = "";
|
||||
parentSearchResults.value = [];
|
||||
showParentDropdown.value = false;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (taskId.value) {
|
||||
await store.fetchTask(taskId.value);
|
||||
@@ -170,12 +239,21 @@ onMounted(async () => {
|
||||
status.value = store.currentTask.status as TaskStatus;
|
||||
priority.value = store.currentTask.priority as TaskPriority;
|
||||
dueDate.value = store.currentTask.due_date || "";
|
||||
const taskRec = store.currentTask as Record<string, unknown>;
|
||||
projectId.value = (taskRec.project_id as number | null) ?? null;
|
||||
milestoneId.value = (taskRec.milestone_id as number | null) ?? null;
|
||||
parentId.value = (taskRec.parent_id as number | null) ?? null;
|
||||
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
|
||||
parentSearchQuery.value = parentTitle.value;
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedStatus = status.value;
|
||||
savedPriority = priority.value;
|
||||
savedDueDate = dueDate.value;
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedParentId = parentId.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -191,6 +269,9 @@ async function save() {
|
||||
status: status.value,
|
||||
priority: priority.value,
|
||||
due_date: dueDate.value || null,
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
parent_id: parentId.value,
|
||||
};
|
||||
if (isEditing.value) {
|
||||
await store.updateTask(taskId.value!, data);
|
||||
@@ -200,6 +281,9 @@ async function save() {
|
||||
savedStatus = status.value;
|
||||
savedPriority = priority.value;
|
||||
savedDueDate = dueDate.value;
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedParentId = parentId.value;
|
||||
dirty.value = false;
|
||||
toast.show("Task saved");
|
||||
} else {
|
||||
@@ -250,13 +334,19 @@ async function autoSave() {
|
||||
status: status.value,
|
||||
priority: priority.value,
|
||||
due_date: dueDate.value || null,
|
||||
});
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
parent_id: parentId.value,
|
||||
} as Record<string, unknown>);
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedStatus = status.value;
|
||||
savedPriority = priority.value;
|
||||
savedDueDate = dueDate.value;
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedParentId = parentId.value;
|
||||
dirty.value = false;
|
||||
toast.show("Auto-saved");
|
||||
} catch {
|
||||
@@ -344,6 +434,49 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
@input="markDirty"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Project</label>
|
||||
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Milestone</label>
|
||||
<MilestoneSelector :projectId="projectId" v-model="milestoneId" @update:modelValue="markDirty" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field parent-field">
|
||||
<label>Parent Task</label>
|
||||
<div class="parent-search-wrapper">
|
||||
<div class="parent-input-row">
|
||||
<input
|
||||
v-model="parentSearchQuery"
|
||||
type="text"
|
||||
class="field-input"
|
||||
placeholder="Search tasks..."
|
||||
@input="onParentSearchInput"
|
||||
@focus="onParentFocus"
|
||||
@blur="hideParentDropdown"
|
||||
/>
|
||||
<button
|
||||
v-if="parentId"
|
||||
class="btn-clear-parent"
|
||||
@click="clearParentTask"
|
||||
title="Clear parent task"
|
||||
>×</button>
|
||||
</div>
|
||||
<div v-if="showParentDropdown" class="parent-dropdown">
|
||||
<div v-if="parentSearchLoading" class="parent-dropdown-item parent-empty">Searching...</div>
|
||||
<div
|
||||
v-for="task in parentSearchResults"
|
||||
:key="task.id"
|
||||
class="parent-dropdown-item"
|
||||
@mousedown.prevent="selectParentTask(task)"
|
||||
>{{ task.title || "Untitled" }}</div>
|
||||
<div v-if="!parentSearchLoading && parentSearchResults.length === 0" class="parent-dropdown-item parent-empty">No tasks found</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TagInput
|
||||
@@ -551,4 +684,49 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Parent task search */
|
||||
.parent-field { max-width: 360px; }
|
||||
.parent-search-wrapper { position: relative; }
|
||||
.parent-input-row { display: flex; align-items: center; gap: 0.25rem; }
|
||||
.parent-input-row .field-input { flex: 1; }
|
||||
.btn-clear-parent {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-clear-parent:hover { color: var(--color-danger, #e74c3c); }
|
||||
.parent-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 12px var(--color-shadow);
|
||||
z-index: 50;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.parent-dropdown-item {
|
||||
padding: 0.45rem 0.7rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.parent-dropdown-item:hover { background: var(--color-bg-secondary); }
|
||||
.parent-empty {
|
||||
color: var(--color-text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
.parent-empty:hover { background: none; }
|
||||
</style>
|
||||
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"aiosmtplib>=3.0",
|
||||
"caldav>=1.3",
|
||||
"icalendar>=5.0",
|
||||
"pywebpush>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -14,6 +14,9 @@ from fabledassistant.routes.auth import auth_bp
|
||||
from fabledassistant.routes.chat import chat_bp
|
||||
from fabledassistant.routes.notes import notes_bp
|
||||
from fabledassistant.routes.images import images_bp
|
||||
from fabledassistant.routes.milestones import milestones_bp
|
||||
from fabledassistant.routes.projects import projects_bp
|
||||
from fabledassistant.routes.push import push_bp
|
||||
from fabledassistant.routes.quick_capture import quick_capture_bp
|
||||
from fabledassistant.routes.settings import settings_bp
|
||||
from fabledassistant.routes.tasks import tasks_bp
|
||||
@@ -46,7 +49,10 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(chat_bp)
|
||||
app.register_blueprint(images_bp)
|
||||
app.register_blueprint(milestones_bp)
|
||||
app.register_blueprint(notes_bp)
|
||||
app.register_blueprint(projects_bp)
|
||||
app.register_blueprint(push_bp)
|
||||
app.register_blueprint(quick_capture_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(tasks_bp)
|
||||
@@ -91,7 +97,8 @@ def create_app() -> Quart:
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; "
|
||||
"connect-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self';"
|
||||
"connect-src 'self'; font-src 'self' data:; object-src 'none'; "
|
||||
"base-uri 'self'; worker-src 'self';"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -60,6 +60,11 @@ class Config:
|
||||
# Maximum size of a single image to cache (default 5 MB)
|
||||
IMAGE_MAX_BYTES: int = int(os.environ.get("IMAGE_MAX_BYTES", str(5 * 1024 * 1024)))
|
||||
|
||||
# VAPID keys for browser push notifications
|
||||
VAPID_PRIVATE_KEY: str = os.environ.get("VAPID_PRIVATE_KEY", "")
|
||||
VAPID_PUBLIC_KEY: str = os.environ.get("VAPID_PUBLIC_KEY", "")
|
||||
VAPID_CLAIMS_SUB: str = os.environ.get("VAPID_CLAIMS_SUB", "mailto:admin@fabledassistant.local")
|
||||
|
||||
@classmethod
|
||||
def oidc_enabled(cls) -> bool:
|
||||
return bool(cls.OIDC_ISSUER and cls.OIDC_CLIENT_ID and cls.OIDC_CLIENT_SECRET)
|
||||
|
||||
@@ -25,3 +25,7 @@ from fabledassistant.models.password_reset import PasswordResetToken # noqa: E4
|
||||
from fabledassistant.models.invitation import InvitationToken # noqa: E402, F401
|
||||
from fabledassistant.models.embedding import NoteEmbedding # noqa: E402, F401
|
||||
from fabledassistant.models.image_cache import ImageCache # noqa: E402, F401
|
||||
from fabledassistant.models.project import Project # noqa: E402, F401
|
||||
from fabledassistant.models.push_subscription import PushSubscription # noqa: E402, F401
|
||||
from fabledassistant.models.event import Event # noqa: E402, F401
|
||||
from fabledassistant.models.milestone import Milestone # noqa: E402, F401
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class Event(Base):
|
||||
__tablename__ = "events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE")
|
||||
)
|
||||
project_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# iCal UID for Radicale linkage (unique per user)
|
||||
uid: Mapped[str] = mapped_column(Text)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
start_dt: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
end_dt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
location: Mapped[str] = mapped_column(Text, default="")
|
||||
recurrence: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"uid": self.uid,
|
||||
"project_id": self.project_id,
|
||||
"title": self.title,
|
||||
"start_dt": self.start_dt.isoformat() if self.start_dt else None,
|
||||
"end_dt": self.end_dt.isoformat() if self.end_dt else None,
|
||||
"all_day": self.all_day,
|
||||
"description": self.description,
|
||||
"location": self.location,
|
||||
"recurrence": self.recurrence,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class Milestone(Base):
|
||||
__tablename__ = "milestones"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
|
||||
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id", ondelete="CASCADE"))
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(Text, default="active")
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"project_id": self.project_id,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"status": self.status,
|
||||
"order_index": self.order_index,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -32,7 +32,13 @@ class Note(Base):
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
nullable=True
|
||||
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
project_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
milestone_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("milestones.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
status: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
priority: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -51,6 +57,8 @@ class Note(Base):
|
||||
Index("ix_notes_status", "status"),
|
||||
Index("ix_notes_title", "title"),
|
||||
Index("ix_notes_user_id", "user_id"),
|
||||
Index("ix_notes_project_id", "project_id"),
|
||||
Index("ix_notes_milestone_id", "milestone_id"),
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -64,6 +72,8 @@ class Note(Base):
|
||||
"body": self.body,
|
||||
"tags": self.tags or [],
|
||||
"parent_id": self.parent_id,
|
||||
"project_id": self.project_id,
|
||||
"milestone_id": self.milestone_id,
|
||||
"status": self.status,
|
||||
"priority": self.priority,
|
||||
"due_date": self.due_date.isoformat() if self.due_date else None,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class ProjectStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
completed = "completed"
|
||||
archived = "archived"
|
||||
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
goal: Mapped[str] = mapped_column(Text, default="")
|
||||
status: Mapped[str] = mapped_column(Text, default="active")
|
||||
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"goal": self.goal,
|
||||
"status": self.status,
|
||||
"color": self.color,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class PushSubscription(Base):
|
||||
__tablename__ = "push_subscriptions"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
|
||||
endpoint: Mapped[str] = mapped_column(Text)
|
||||
p256dh: Mapped[str] = mapped_column(Text)
|
||||
auth: Mapped[str] = mapped_column(Text)
|
||||
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
last_used: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "endpoint", name="uq_push_subscription_user_endpoint"),
|
||||
)
|
||||
@@ -114,6 +114,7 @@ async def send_message_route(conv_id: int):
|
||||
return jsonify({"error": "content is required"}), 400
|
||||
context_note_id = data.get("context_note_id")
|
||||
include_note_ids = data.get("include_note_ids") or []
|
||||
excluded_note_ids = data.get("excluded_note_ids") or []
|
||||
think = bool(data.get("think", False))
|
||||
|
||||
# Reject if generation already running for this conversation
|
||||
@@ -143,6 +144,7 @@ async def send_message_route(conv_id: int):
|
||||
uid, conv_id, conv.title, content,
|
||||
context_note_id=context_note_id,
|
||||
include_note_ids=include_note_ids,
|
||||
excluded_note_ids=excluded_note_ids,
|
||||
think=think,
|
||||
))
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Milestone routes nested under /api/projects/<project_id>/milestones."""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.services.milestones import (
|
||||
create_milestone,
|
||||
delete_milestone,
|
||||
get_milestone,
|
||||
get_milestone_progress,
|
||||
list_milestones,
|
||||
update_milestone,
|
||||
)
|
||||
from fabledassistant.services.notes import list_notes
|
||||
from fabledassistant.services.projects import get_project
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
milestones_bp = Blueprint("milestones", __name__, url_prefix="/api/projects")
|
||||
|
||||
|
||||
@milestones_bp.route("/<int:project_id>/milestones", methods=["GET"])
|
||||
@login_required
|
||||
async def list_milestones_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
project = await get_project(uid, project_id)
|
||||
if project is None:
|
||||
return jsonify({"error": "Project not found"}), 404
|
||||
status = request.args.get("status")
|
||||
milestones = await list_milestones(uid, project_id, status=status)
|
||||
result = []
|
||||
for m in milestones:
|
||||
entry = m.to_dict()
|
||||
progress = await get_milestone_progress(m.id)
|
||||
entry.update(progress)
|
||||
result.append(entry)
|
||||
return jsonify({"milestones": result})
|
||||
|
||||
|
||||
@milestones_bp.route("/<int:project_id>/milestones", methods=["POST"])
|
||||
@login_required
|
||||
async def create_milestone_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
project = await get_project(uid, project_id)
|
||||
if project is None:
|
||||
return jsonify({"error": "Project not found"}), 404
|
||||
data = await request.get_json()
|
||||
if not data.get("title"):
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
milestone = await create_milestone(
|
||||
uid,
|
||||
project_id,
|
||||
title=data["title"],
|
||||
description=data.get("description"),
|
||||
order_index=data.get("order_index", 0),
|
||||
)
|
||||
entry = milestone.to_dict()
|
||||
entry.update(await get_milestone_progress(milestone.id))
|
||||
return jsonify(entry), 201
|
||||
|
||||
|
||||
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["GET"])
|
||||
@login_required
|
||||
async def get_milestone_route(project_id: int, milestone_id: int):
|
||||
uid = get_current_user_id()
|
||||
milestone = await get_milestone(uid, milestone_id)
|
||||
if milestone is None or milestone.project_id != project_id:
|
||||
return jsonify({"error": "Milestone not found"}), 404
|
||||
entry = milestone.to_dict()
|
||||
entry.update(await get_milestone_progress(milestone.id))
|
||||
return jsonify(entry)
|
||||
|
||||
|
||||
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_milestone_route(project_id: int, milestone_id: int):
|
||||
uid = get_current_user_id()
|
||||
milestone = await get_milestone(uid, milestone_id)
|
||||
if milestone is None or milestone.project_id != project_id:
|
||||
return jsonify({"error": "Milestone not found"}), 404
|
||||
data = await request.get_json()
|
||||
allowed = {"title", "description", "status", "order_index"}
|
||||
fields = {k: v for k, v in data.items() if k in allowed}
|
||||
updated = await update_milestone(uid, milestone_id, **fields)
|
||||
if updated is None:
|
||||
return jsonify({"error": "Milestone not found"}), 404
|
||||
entry = updated.to_dict()
|
||||
entry.update(await get_milestone_progress(updated.id))
|
||||
return jsonify(entry)
|
||||
|
||||
|
||||
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def delete_milestone_route(project_id: int, milestone_id: int):
|
||||
uid = get_current_user_id()
|
||||
milestone = await get_milestone(uid, milestone_id)
|
||||
if milestone is None or milestone.project_id != project_id:
|
||||
return jsonify({"error": "Milestone not found"}), 404
|
||||
deleted = await delete_milestone(uid, milestone_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "Milestone not found"}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>/tasks", methods=["GET"])
|
||||
@login_required
|
||||
async def get_milestone_tasks_route(project_id: int, milestone_id: int):
|
||||
uid = get_current_user_id()
|
||||
milestone = await get_milestone(uid, milestone_id)
|
||||
if milestone is None or milestone.project_id != project_id:
|
||||
return jsonify({"error": "Milestone not found"}), 404
|
||||
status_filter = request.args.get("status")
|
||||
limit = min(request.args.get("limit", 100, type=int), 500)
|
||||
offset = request.args.get("offset", 0, type=int)
|
||||
notes, total = await list_notes(
|
||||
uid,
|
||||
is_task=True,
|
||||
status=status_filter,
|
||||
milestone_id=milestone_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
|
||||
@@ -84,6 +84,8 @@ async def create_note_route():
|
||||
body=body,
|
||||
tags=tags,
|
||||
parent_id=data.get("parent_id"),
|
||||
project_id=data.get("project_id"),
|
||||
milestone_id=data.get("milestone_id"),
|
||||
status=status,
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
@@ -176,7 +178,7 @@ async def update_note_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
fields = {}
|
||||
for key in ("title", "body", "parent_id", "status", "priority"):
|
||||
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority"):
|
||||
if key in data:
|
||||
fields[key] = data[key]
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Project management routes."""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.services.notes import list_notes
|
||||
from fabledassistant.services.projects import (
|
||||
create_project,
|
||||
delete_project,
|
||||
get_project,
|
||||
get_project_summary,
|
||||
list_projects,
|
||||
update_project,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
projects_bp = Blueprint("projects", __name__, url_prefix="/api/projects")
|
||||
|
||||
|
||||
@projects_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def list_projects_route():
|
||||
uid = get_current_user_id()
|
||||
status = request.args.get("status")
|
||||
projects = await list_projects(uid, status=status)
|
||||
return jsonify({"projects": [p.to_dict() for p in projects]})
|
||||
|
||||
|
||||
@projects_bp.route("", methods=["POST"])
|
||||
@login_required
|
||||
async def create_project_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
if not data.get("title"):
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
project = await create_project(
|
||||
uid,
|
||||
title=data["title"],
|
||||
description=data.get("description", ""),
|
||||
goal=data.get("goal", ""),
|
||||
color=data.get("color"),
|
||||
)
|
||||
return jsonify(project.to_dict()), 201
|
||||
|
||||
|
||||
@projects_bp.route("/<int:project_id>", methods=["GET"])
|
||||
@login_required
|
||||
async def get_project_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
project = await get_project(uid, project_id)
|
||||
if project is None:
|
||||
return jsonify({"error": "Project not found"}), 404
|
||||
summary = await get_project_summary(uid, project_id)
|
||||
data = project.to_dict()
|
||||
data["summary"] = summary
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@projects_bp.route("/<int:project_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_project_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
allowed = {"title", "description", "goal", "status", "color"}
|
||||
fields = {k: v for k, v in data.items() if k in allowed}
|
||||
project = await update_project(uid, project_id, **fields)
|
||||
if project is None:
|
||||
return jsonify({"error": "Project not found"}), 404
|
||||
return jsonify(project.to_dict())
|
||||
|
||||
|
||||
@projects_bp.route("/<int:project_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def delete_project_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
deleted = await delete_project(uid, project_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "Project not found"}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@projects_bp.route("/<int:project_id>/notes", methods=["GET"])
|
||||
@login_required
|
||||
async def get_project_notes_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
project = await get_project(uid, project_id)
|
||||
if project is None:
|
||||
return jsonify({"error": "Project not found"}), 404
|
||||
|
||||
# type filter: "note", "task", or None (both)
|
||||
type_filter = request.args.get("type")
|
||||
status_filter = request.args.get("status")
|
||||
limit = min(request.args.get("limit", 100, type=int), 500)
|
||||
offset = request.args.get("offset", 0, type=int)
|
||||
|
||||
is_task: bool | None = None
|
||||
if type_filter == "task":
|
||||
is_task = True
|
||||
elif type_filter == "note":
|
||||
is_task = False
|
||||
|
||||
notes, total = await list_notes(
|
||||
uid,
|
||||
is_task=is_task,
|
||||
status=status_filter,
|
||||
project_id=project_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
sort="updated_at",
|
||||
order="desc",
|
||||
)
|
||||
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Push notification subscription routes."""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.push import delete_subscription, save_subscription, vapid_enabled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
push_bp = Blueprint("push", __name__, url_prefix="/api/push")
|
||||
|
||||
|
||||
@push_bp.route("/vapid-public-key", methods=["GET"])
|
||||
@login_required
|
||||
async def get_vapid_key():
|
||||
if not vapid_enabled():
|
||||
return jsonify({"error": "Push notifications not configured"}), 503
|
||||
return jsonify({"publicKey": Config.VAPID_PUBLIC_KEY})
|
||||
|
||||
|
||||
@push_bp.route("/subscribe", methods=["POST"])
|
||||
@login_required
|
||||
async def subscribe():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
if not data or not data.get("endpoint"):
|
||||
return jsonify({"error": "Invalid subscription data"}), 400
|
||||
user_agent = request.headers.get("User-Agent")
|
||||
sub = await save_subscription(uid, data, user_agent=user_agent)
|
||||
return jsonify({"id": sub.id}), 201
|
||||
|
||||
|
||||
@push_bp.route("/subscribe", methods=["DELETE"])
|
||||
@login_required
|
||||
async def unsubscribe():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
endpoint = (data or {}).get("endpoint", "")
|
||||
if not endpoint:
|
||||
return jsonify({"error": "endpoint is required"}), 400
|
||||
await delete_subscription(uid, endpoint)
|
||||
return "", 204
|
||||
@@ -16,13 +16,13 @@ CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "cald
|
||||
|
||||
|
||||
async def get_caldav_config(user_id: int) -> dict[str, str]:
|
||||
"""Read CalDAV settings for the given user."""
|
||||
"""Return the user's CalDAV config from their settings."""
|
||||
all_settings = await get_all_settings(user_id)
|
||||
return {k: all_settings.get(k, "") for k in CALDAV_SETTING_KEYS}
|
||||
|
||||
|
||||
async def is_caldav_configured(user_id: int) -> bool:
|
||||
"""Check if CalDAV URL, username, and password are all set."""
|
||||
"""Check if the user has configured an external CalDAV server."""
|
||||
config = await get_caldav_config(user_id)
|
||||
return bool(config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password"))
|
||||
|
||||
@@ -55,8 +55,8 @@ def _make_client(config: dict[str, str]) -> caldav.DAVClient:
|
||||
"""Create a CalDAV client from config dict."""
|
||||
return caldav.DAVClient(
|
||||
url=config["caldav_url"],
|
||||
username=config["caldav_username"],
|
||||
password=config["caldav_password"],
|
||||
username=config.get("caldav_username") or None,
|
||||
password=config.get("caldav_password") or None,
|
||||
)
|
||||
|
||||
|
||||
@@ -158,8 +158,8 @@ def _add_attendees(event: icalendar.Event, attendees: list[str]) -> None:
|
||||
|
||||
def _check_config(config: dict[str, str]) -> None:
|
||||
"""Raise if CalDAV is not configured."""
|
||||
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
|
||||
raise ValueError("CalDAV is not configured. Go to Settings -> Calendar to set it up.")
|
||||
if not config.get("caldav_url"):
|
||||
raise ValueError("CalDAV is not configured. Go to Settings → Calendar to enter your server URL.")
|
||||
|
||||
|
||||
async def create_event(
|
||||
@@ -707,8 +707,8 @@ async def delete_todo(
|
||||
async def test_connection(user_id: int) -> dict:
|
||||
"""Test the CalDAV connection and return status."""
|
||||
config = await get_caldav_config(user_id)
|
||||
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
|
||||
return {"success": False, "error": "CalDAV is not configured. Please fill in URL, username, and password."}
|
||||
if not config.get("caldav_url"):
|
||||
return {"success": False, "error": "CalDAV is not configured."}
|
||||
|
||||
def _test():
|
||||
client = _make_client(config)
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Calendar sync service: bridges the DB Event table with local Radicale.
|
||||
|
||||
Each user's calendars live at: http://radicale:5232/user_{user_id}/calendar/
|
||||
The app connects without auth (Radicale runs with auth.type = none inside Docker).
|
||||
|
||||
External clients (iOS, macOS, Thunderbird) connect directly to Radicale on port 5232.
|
||||
Their CalDAV URL is: http://<host>:5232/user_{user_id}/calendar/
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import icalendar
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.event import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Internal Radicale URL (Docker service name)
|
||||
_RADICALE_INTERNAL = "http://radicale:5232"
|
||||
|
||||
|
||||
def _user_calendar_path(user_id: int) -> str:
|
||||
return f"/user_{user_id}/calendar/"
|
||||
|
||||
|
||||
def _user_calendar_url(user_id: int) -> str:
|
||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
||||
return f"{base}/user_{user_id}/calendar/"
|
||||
|
||||
|
||||
async def setup_user_calendar(user_id: int) -> bool:
|
||||
"""Create Radicale collection for user if it doesn't exist.
|
||||
|
||||
Returns True on success or if already exists, False on error.
|
||||
"""
|
||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
||||
user_url = f"{base}/user_{user_id}/"
|
||||
cal_url = f"{base}/user_{user_id}/calendar/"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Create user home collection
|
||||
await client.request(
|
||||
"MKCOL",
|
||||
user_url,
|
||||
headers={"Content-Type": "text/xml"},
|
||||
content="""<?xml version="1.0" encoding="utf-8"?>
|
||||
<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<set><prop><resourcetype><collection/></resourcetype></prop></set>
|
||||
</mkcol>""",
|
||||
)
|
||||
# Create calendar collection
|
||||
resp = await client.request(
|
||||
"MKCOL",
|
||||
cal_url,
|
||||
headers={"Content-Type": "text/xml"},
|
||||
content="""<?xml version="1.0" encoding="utf-8"?>
|
||||
<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<set>
|
||||
<prop>
|
||||
<resourcetype><collection/><C:calendar/></resourcetype>
|
||||
<displayname>Fabled Assistant</displayname>
|
||||
</prop>
|
||||
</set>
|
||||
</mkcol>""",
|
||||
)
|
||||
if resp.status_code in (201, 405): # 405 = already exists
|
||||
logger.info("Calendar collection ready for user %d", user_id)
|
||||
return True
|
||||
logger.warning("Unexpected status %d creating calendar for user %d", resp.status_code, user_id)
|
||||
return False
|
||||
except Exception:
|
||||
logger.warning("Failed to setup Radicale calendar for user %d", user_id, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _make_vcalendar(event: Event) -> str:
|
||||
"""Build iCalendar string from DB Event."""
|
||||
cal = icalendar.Calendar()
|
||||
cal.add("prodid", "-//FabledAssistant//EN")
|
||||
cal.add("version", "2.0")
|
||||
|
||||
vevent = icalendar.Event()
|
||||
vevent.add("uid", event.uid)
|
||||
vevent.add("summary", event.title)
|
||||
|
||||
if event.all_day:
|
||||
vevent.add("dtstart", event.start_dt.date())
|
||||
if event.end_dt:
|
||||
vevent.add("dtend", event.end_dt.date())
|
||||
else:
|
||||
vevent.add("dtstart", event.start_dt)
|
||||
if event.end_dt:
|
||||
vevent.add("dtend", event.end_dt)
|
||||
|
||||
if event.description:
|
||||
vevent.add("description", event.description)
|
||||
if event.location:
|
||||
vevent.add("location", event.location)
|
||||
if event.recurrence:
|
||||
rrule_parts: dict = {}
|
||||
for part in event.recurrence.split(";"):
|
||||
if "=" in part:
|
||||
key, value = part.split("=", 1)
|
||||
rrule_parts[key.strip().lower()] = value.strip()
|
||||
if rrule_parts:
|
||||
vevent.add("rrule", rrule_parts)
|
||||
|
||||
vevent.add("dtstamp", datetime.now(timezone.utc))
|
||||
cal.add_component(vevent)
|
||||
return cal.to_ical().decode("utf-8")
|
||||
|
||||
|
||||
async def push_event_to_radicale(user_id: int, event: Event) -> bool:
|
||||
"""Write a DB Event to Radicale. Creates or updates the resource.
|
||||
|
||||
Returns True on success.
|
||||
"""
|
||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
||||
resource_url = f"{base}/user_{user_id}/calendar/{event.uid}.ics"
|
||||
ical_data = _make_vcalendar(event)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.put(
|
||||
resource_url,
|
||||
content=ical_data.encode("utf-8"),
|
||||
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
||||
)
|
||||
if resp.status_code in (200, 201, 204):
|
||||
return True
|
||||
logger.warning("Radicale PUT returned %d for event %s", resp.status_code, event.uid)
|
||||
return False
|
||||
except Exception:
|
||||
logger.warning("Failed to push event %s to Radicale", event.uid, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
async def delete_event_from_radicale(user_id: int, uid: str) -> bool:
|
||||
"""Delete an event from Radicale by UID."""
|
||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
||||
resource_url = f"{base}/user_{user_id}/calendar/{uid}.ics"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.delete(resource_url)
|
||||
return resp.status_code in (200, 204, 404) # 404 is ok (already gone)
|
||||
except Exception:
|
||||
logger.warning("Failed to delete event %s from Radicale", uid, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
|
||||
"""Fetch an event from Radicale and upsert into the DB Event table.
|
||||
|
||||
Returns the upserted Event or None on error.
|
||||
"""
|
||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
||||
resource_url = f"{base}/user_{user_id}/calendar/{ical_uid}.ics"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(resource_url)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
ical_data = resp.text
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch event %s from Radicale", ical_uid, exc_info=True)
|
||||
return None
|
||||
|
||||
try:
|
||||
cal = icalendar.Calendar.from_ical(ical_data)
|
||||
for component in cal.walk():
|
||||
if component.name != "VEVENT":
|
||||
continue
|
||||
title = str(component.get("SUMMARY", ""))
|
||||
dtstart = component.get("DTSTART")
|
||||
dtend = component.get("DTEND")
|
||||
description = str(component.get("DESCRIPTION", ""))
|
||||
location = str(component.get("LOCATION", ""))
|
||||
rrule = component.get("RRULE")
|
||||
recurrence = rrule.to_ical().decode("utf-8") if rrule else None
|
||||
|
||||
all_day = False
|
||||
if dtstart and isinstance(dtstart.dt, datetime):
|
||||
start_dt = dtstart.dt if dtstart.dt.tzinfo else dtstart.dt.replace(tzinfo=timezone.utc)
|
||||
elif dtstart:
|
||||
from datetime import date
|
||||
d = dtstart.dt
|
||||
start_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
||||
all_day = True
|
||||
else:
|
||||
continue
|
||||
|
||||
end_dt = None
|
||||
if dtend and isinstance(dtend.dt, datetime):
|
||||
end_dt = dtend.dt if dtend.dt.tzinfo else dtend.dt.replace(tzinfo=timezone.utc)
|
||||
elif dtend:
|
||||
from datetime import date
|
||||
d = dtend.dt
|
||||
end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(Event.user_id == user_id, Event.uid == ical_uid)
|
||||
)
|
||||
existing = result.scalars().first()
|
||||
if existing:
|
||||
existing.title = title
|
||||
existing.start_dt = start_dt
|
||||
existing.end_dt = end_dt
|
||||
existing.all_day = all_day
|
||||
existing.description = description
|
||||
existing.location = location
|
||||
existing.recurrence = recurrence
|
||||
existing.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
return existing
|
||||
else:
|
||||
event_obj = Event(
|
||||
user_id=user_id,
|
||||
uid=ical_uid,
|
||||
title=title,
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
all_day=all_day,
|
||||
description=description,
|
||||
location=location,
|
||||
recurrence=recurrence,
|
||||
)
|
||||
session.add(event_obj)
|
||||
await session.commit()
|
||||
await session.refresh(event_obj)
|
||||
return event_obj
|
||||
except Exception:
|
||||
logger.warning("Failed to parse/upsert Radicale event %s", ical_uid, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def sync_all_events_from_radicale(user_id: int) -> int:
|
||||
"""Full sync: fetch all events from Radicale calendar and upsert into DB.
|
||||
|
||||
Returns number of events synced.
|
||||
"""
|
||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
||||
cal_url = f"{base}/user_{user_id}/calendar/"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# PROPFIND to get all .ics resources
|
||||
resp = await client.request(
|
||||
"PROPFIND",
|
||||
cal_url,
|
||||
headers={"Depth": "1", "Content-Type": "text/xml"},
|
||||
content="""<?xml version="1.0" encoding="utf-8"?>
|
||||
<propfind xmlns="DAV:"><prop><getetag/><getcontenttype/></prop></propfind>""",
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
logger.info("No calendar found for user %d — setting up", user_id)
|
||||
await setup_user_calendar(user_id)
|
||||
return 0
|
||||
if resp.status_code != 207:
|
||||
logger.warning("PROPFIND returned %d for user %d", resp.status_code, user_id)
|
||||
return 0
|
||||
# Extract UIDs from href paths
|
||||
import re
|
||||
hrefs = re.findall(r"<[Dd]:[Hh]ref>([^<]+\.ics)</[Dd]:[Hh]ref>", resp.text)
|
||||
uids = [h.rstrip("/").split("/")[-1].removesuffix(".ics") for h in hrefs]
|
||||
except Exception:
|
||||
logger.warning("Failed to list Radicale events for user %d", user_id, exc_info=True)
|
||||
return 0
|
||||
|
||||
synced = 0
|
||||
for uid in uids:
|
||||
if uid:
|
||||
event = await sync_event_to_db(user_id, uid)
|
||||
if event:
|
||||
synced += 1
|
||||
|
||||
logger.info("Synced %d events from Radicale for user %d", synced, user_id)
|
||||
return synced
|
||||
|
||||
|
||||
def get_external_caldav_url(user_id: int) -> str:
|
||||
"""Return the CalDAV URL for external clients (iOS, macOS, Thunderbird).
|
||||
|
||||
Uses BASE_URL from Config with port 5232 substituted, or RADICALE_EXTERNAL_URL.
|
||||
"""
|
||||
external = Config.RADICALE_EXTERNAL_URL
|
||||
if external:
|
||||
return f"{external.rstrip('/')}/user_{user_id}/calendar/"
|
||||
# Fall back to BASE_URL host with port 5232
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(Config.BASE_URL)
|
||||
host = parsed.hostname or "localhost"
|
||||
return f"http://{host}:5232/user_{user_id}/calendar/"
|
||||
@@ -78,11 +78,12 @@ async def semantic_search_notes(
|
||||
query: str,
|
||||
exclude_ids: set[int] | None = None,
|
||||
limit: int = 8,
|
||||
threshold: float = _SIMILARITY_THRESHOLD,
|
||||
) -> list[tuple[float, Note]]:
|
||||
"""Return up to *limit* (score, note) pairs most relevant to *query*.
|
||||
|
||||
Scores are cosine similarities in [-1, 1]; only notes at or above
|
||||
_SIMILARITY_THRESHOLD are returned, sorted highest-first.
|
||||
*threshold* are returned, sorted highest-first.
|
||||
Returns an empty list if the embedding model is unavailable or on any error.
|
||||
"""
|
||||
try:
|
||||
@@ -114,7 +115,7 @@ async def semantic_search_notes(
|
||||
sim = _cosine_similarity(query_vec, ne.embedding)
|
||||
except Exception:
|
||||
continue
|
||||
if sim >= _SIMILARITY_THRESHOLD:
|
||||
if sim >= threshold:
|
||||
scored.append((sim, note))
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
@@ -147,6 +147,7 @@ async def run_generation(
|
||||
user_content: str,
|
||||
context_note_id: int | None = None,
|
||||
include_note_ids: list[int] | None = None,
|
||||
excluded_note_ids: list[int] | None = None,
|
||||
think: bool = False,
|
||||
) -> None:
|
||||
"""Stream LLM response into buffer with periodic DB flushes."""
|
||||
@@ -166,7 +167,7 @@ async def run_generation(
|
||||
# Phase 2: Summarize long conversation history if needed.
|
||||
history_to_use = history
|
||||
history_summary: str | None = None
|
||||
if len(history) > 20: # matches _HISTORY_SUMMARY_THRESHOLD in llm.py
|
||||
if len(history) > 30: # matches _HISTORY_SUMMARY_THRESHOLD in llm.py
|
||||
buf.append_event("status", {"status": "Summarizing conversation history..."})
|
||||
history_to_use, history_summary = await summarize_history_for_context(history, model)
|
||||
|
||||
@@ -177,6 +178,7 @@ async def run_generation(
|
||||
user_id, history_to_use, context_note_id, user_content,
|
||||
history_summary=history_summary,
|
||||
include_note_ids=include_note_ids,
|
||||
excluded_note_ids=excluded_note_ids,
|
||||
))
|
||||
|
||||
messages, context_meta = await context_task
|
||||
@@ -348,6 +350,22 @@ async def run_generation(
|
||||
buf.finished_at = time.monotonic()
|
||||
buf.append_event("done", {"done": True, "message_id": msg_id, "timing": timing})
|
||||
|
||||
# Fire push notification when complete (non-critical, fire-and-forget)
|
||||
try:
|
||||
from fabledassistant.services.push import send_push_notification, vapid_enabled
|
||||
if vapid_enabled():
|
||||
preview = buf.content_so_far[:120].rstrip()
|
||||
if len(buf.content_so_far) > 120:
|
||||
preview += "…"
|
||||
asyncio.create_task(send_push_notification(
|
||||
user_id,
|
||||
title="Response ready",
|
||||
body=preview,
|
||||
url=f"/chat/{conv_id}",
|
||||
))
|
||||
except Exception:
|
||||
logger.debug("Failed to schedule push notification", exc_info=True)
|
||||
|
||||
# Title generation is non-critical — fire-and-forget so done fires immediately
|
||||
non_system = [m for m in messages if m["role"] != "system"]
|
||||
msg_count = len(non_system)
|
||||
|
||||
@@ -132,6 +132,7 @@ Rules:
|
||||
- create_event: appointments, meetings, scheduled occurrences with a date/time ("dentist Friday 2pm", "team meeting next Tuesday")
|
||||
- update_note: updating, editing, appending to an existing note or task ("add to my shopping list: eggs", "mark buy milk done", "append to my meeting notes", "update my project note")
|
||||
- research_topic: user wants a comprehensive research note from web sources ("research X", "look up X and make a note", "find everything about X", "compile a note on X")
|
||||
- create_project: user wants to create a project, initiative, or campaign ("new project X", "start a project called Y", "create a project for Z")
|
||||
- create_note: everything else — ideas, observations, links, excerpts, longer text
|
||||
- For create_task / create_event: extract a concise title; put any extra detail in "body"
|
||||
- For create_note: use a short descriptive title (≤60 chars); put the FULL original text as "body"
|
||||
|
||||
@@ -27,6 +27,10 @@ STOP_WORDS = frozenset({
|
||||
"but", "if", "so", "than", "too", "very", "just", "about", "up",
|
||||
})
|
||||
|
||||
RAG_AUTO_THRESHOLD = 0.60
|
||||
RAG_AUTO_LIMIT = 3
|
||||
RAG_AUTO_SNIPPET = 800
|
||||
|
||||
|
||||
async def get_installed_models() -> set[str]:
|
||||
"""Return set of installed Ollama model names (with and without :latest)."""
|
||||
@@ -302,8 +306,8 @@ def _find_urls(text: str) -> list[str]:
|
||||
|
||||
|
||||
# History summarization thresholds
|
||||
_HISTORY_SUMMARY_THRESHOLD = 20 # total messages before summarizing
|
||||
_HISTORY_KEEP_RECENT = 6 # verbatim tail to preserve (3 exchanges)
|
||||
_HISTORY_SUMMARY_THRESHOLD = 30 # total messages before summarizing
|
||||
_HISTORY_KEEP_RECENT = 8 # verbatim tail to preserve (4 exchanges)
|
||||
|
||||
|
||||
async def summarize_history_for_context(
|
||||
@@ -324,13 +328,55 @@ async def summarize_history_for_context(
|
||||
to_summarize = history[:-_HISTORY_KEEP_RECENT]
|
||||
recent = history[-_HISTORY_KEEP_RECENT:]
|
||||
|
||||
lines: list[str] = []
|
||||
for m in to_summarize:
|
||||
role = m.get("role", "")
|
||||
content = (m.get("content") or "").strip()
|
||||
if role in ("user", "assistant") and content:
|
||||
label = "User" if role == "user" else "Assistant"
|
||||
lines.append(f"{label}: {content[:400]}")
|
||||
# Two-pass for very long histories: summarize first half, combine with second half
|
||||
if len(to_summarize) > 50:
|
||||
mid = len(to_summarize) // 2
|
||||
first_half = to_summarize[:mid]
|
||||
second_half = to_summarize[mid:]
|
||||
|
||||
# Summarize first half
|
||||
first_lines = []
|
||||
for m in first_half:
|
||||
role = m.get("role", "")
|
||||
content = (m.get("content") or "").strip()
|
||||
if role in ("user", "assistant") and content:
|
||||
label = "User" if role == "user" else "Assistant"
|
||||
first_lines.append(f"{label}: {content[:400]}")
|
||||
|
||||
if first_lines:
|
||||
try:
|
||||
first_summary_messages = [
|
||||
{"role": "system", "content": "Summarize this conversation in 3-4 sentences covering topics, notes/tasks created, and key decisions."},
|
||||
{"role": "user", "content": "\n".join(first_lines)},
|
||||
]
|
||||
summary_a = await generate_completion(first_summary_messages, model, max_tokens=300)
|
||||
summary_a = summary_a.strip()
|
||||
except Exception:
|
||||
summary_a = ""
|
||||
else:
|
||||
summary_a = ""
|
||||
|
||||
# Build lines for final pass from second half
|
||||
second_lines = []
|
||||
for m in second_half:
|
||||
role = m.get("role", "")
|
||||
content = (m.get("content") or "").strip()
|
||||
if role in ("user", "assistant") and content:
|
||||
label = "User" if role == "user" else "Assistant"
|
||||
second_lines.append(f"{label}: {content[:400]}")
|
||||
|
||||
if summary_a:
|
||||
lines = [f"[Earlier summary: {summary_a}]"] + second_lines
|
||||
else:
|
||||
lines = second_lines
|
||||
else:
|
||||
lines: list[str] = []
|
||||
for m in to_summarize:
|
||||
role = m.get("role", "")
|
||||
content = (m.get("content") or "").strip()
|
||||
if role in ("user", "assistant") and content:
|
||||
label = "User" if role == "user" else "Assistant"
|
||||
lines.append(f"{label}: {content[:400]}")
|
||||
|
||||
if not lines:
|
||||
return history, None
|
||||
@@ -339,17 +385,19 @@ async def summarize_history_for_context(
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Summarize this conversation history in 3-5 concise sentences. "
|
||||
"Capture: topics discussed, any notes/tasks/events created or modified, "
|
||||
"decisions made, and context needed to continue the conversation naturally. "
|
||||
"Be specific and factual. Output only the summary, nothing else."
|
||||
"Summarize this conversation history. Capture: "
|
||||
"(1) All notes, tasks, and projects created or modified — include their exact names. "
|
||||
"(2) Key decisions made and conclusions reached. "
|
||||
"(3) Open questions and next steps mentioned. "
|
||||
"(4) The overall topic arc so the conversation can continue naturally. "
|
||||
"Be specific and factual. Output 4-8 concise sentences. Nothing else."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "\n".join(lines)},
|
||||
]
|
||||
|
||||
try:
|
||||
summary = await generate_completion(prompt_messages, model, max_tokens=200)
|
||||
summary = await generate_completion(prompt_messages, model, max_tokens=400)
|
||||
summary = summary.strip()
|
||||
if summary:
|
||||
logger.info(
|
||||
@@ -371,6 +419,7 @@ async def build_context(
|
||||
exclude_note_ids: list[int] | None = None,
|
||||
history_summary: str | None = None,
|
||||
include_note_ids: list[int] | None = None,
|
||||
excluded_note_ids: list[int] | None = None,
|
||||
) -> tuple[list[dict], dict]:
|
||||
"""Build messages array for Ollama with system prompt and context.
|
||||
|
||||
@@ -426,6 +475,7 @@ async def build_context(
|
||||
"context_note_id": None,
|
||||
"context_note_title": None,
|
||||
"auto_notes": [],
|
||||
"auto_injected_notes": [],
|
||||
}
|
||||
|
||||
# Include current note context if provided — full body, no truncation
|
||||
@@ -441,10 +491,9 @@ async def build_context(
|
||||
f"--- End Note ---"
|
||||
)
|
||||
|
||||
# Search for related notes to populate sidebar candidates.
|
||||
# Results are NOT injected into the system prompt automatically — this keeps
|
||||
# the system prompt prefix stable so Ollama's KV cache can reuse prefill state.
|
||||
# Users can explicitly include notes via the sidebar (include_note_ids).
|
||||
# Search for related notes. High-confidence results (>=0.60) are auto-injected
|
||||
# into the system prompt; lower-confidence results populate the sidebar only.
|
||||
# Users can also explicitly include notes via the sidebar (include_note_ids).
|
||||
search_exclude = set(exclude_set)
|
||||
if current_note_id:
|
||||
search_exclude.add(current_note_id)
|
||||
@@ -473,12 +522,54 @@ async def build_context(
|
||||
except Exception:
|
||||
logger.warning("Failed to search notes for context", exc_info=True)
|
||||
|
||||
# Populate sidebar candidates (never auto-injected into system prompt).
|
||||
# Separate high-confidence results for auto-injection vs sidebar display
|
||||
excluded_inject_set = set(excluded_note_ids or [])
|
||||
auto_inject: list[tuple[float, object]] = []
|
||||
sidebar_only: list[tuple[float | None, object]] = []
|
||||
|
||||
for score, n in found_scored:
|
||||
if (
|
||||
score is not None
|
||||
and score >= RAG_AUTO_THRESHOLD
|
||||
and len(auto_inject) < RAG_AUTO_LIMIT
|
||||
and n.id not in excluded_inject_set
|
||||
):
|
||||
auto_inject.append((score, n))
|
||||
else:
|
||||
sidebar_only.append((score, n))
|
||||
|
||||
# Inject high-scoring notes into system prompt
|
||||
if auto_inject:
|
||||
snippets = []
|
||||
for score, n in auto_inject:
|
||||
body_snippet = (n.body or "")[:RAG_AUTO_SNIPPET]
|
||||
snippets.append(f"**{n.title}** (relevance: {round(score * 100)}%)\n{body_snippet}")
|
||||
context_meta["auto_injected_notes"].append({
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"score": round(score, 2),
|
||||
})
|
||||
system_parts.append(
|
||||
"\n\n--- Relevant Notes ---\n"
|
||||
+ "\n\n".join(snippets)
|
||||
+ "\n--- End Relevant Notes ---"
|
||||
)
|
||||
|
||||
# Populate sidebar candidates (auto-injected notes also appear here for reference,
|
||||
# but sidebar_only are the ones not yet in the prompt)
|
||||
for score, n in auto_inject:
|
||||
context_meta["auto_notes"].append({
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"score": round(score, 2) if score is not None else None,
|
||||
"auto_injected": True,
|
||||
})
|
||||
for score, n in sidebar_only:
|
||||
context_meta["auto_notes"].append({
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"score": round(score, 2) if score is not None else None,
|
||||
"auto_injected": False,
|
||||
})
|
||||
context_meta["auto_note_ids"] = [n.id for _, n in found_scored]
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Milestone management service."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.milestone import Milestone
|
||||
from fabledassistant.models.note import Note
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_milestone(
|
||||
user_id: int,
|
||||
project_id: int,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
order_index: int = 0,
|
||||
) -> Milestone:
|
||||
async with async_session() as session:
|
||||
milestone = Milestone(
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
title=title,
|
||||
description=description,
|
||||
status="active",
|
||||
order_index=order_index,
|
||||
)
|
||||
session.add(milestone)
|
||||
await session.commit()
|
||||
await session.refresh(milestone)
|
||||
return milestone
|
||||
|
||||
|
||||
async def get_milestone(user_id: int, milestone_id: int) -> Milestone | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_milestone_by_title(user_id: int, project_id: int, title: str) -> Milestone | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Milestone).where(
|
||||
Milestone.user_id == user_id,
|
||||
Milestone.project_id == project_id,
|
||||
func.lower(Milestone.title) == func.lower(title.strip()),
|
||||
).limit(1)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_or_create_milestone(user_id: int, project_id: int, title: str) -> Milestone:
|
||||
milestone = await get_milestone_by_title(user_id, project_id, title)
|
||||
if milestone:
|
||||
return milestone
|
||||
return await create_milestone(user_id, project_id, title=title)
|
||||
|
||||
|
||||
async def list_milestones(
|
||||
user_id: int, project_id: int, status: str | None = None
|
||||
) -> list[Milestone]:
|
||||
async with async_session() as session:
|
||||
query = select(Milestone).where(
|
||||
Milestone.user_id == user_id,
|
||||
Milestone.project_id == project_id,
|
||||
)
|
||||
if status:
|
||||
query = query.where(Milestone.status == status)
|
||||
query = query.order_by(Milestone.order_index.asc(), Milestone.created_at.asc())
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_milestone(user_id: int, milestone_id: int, **fields: object) -> Milestone | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
|
||||
)
|
||||
milestone = result.scalars().first()
|
||||
if milestone is None:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
if hasattr(milestone, key):
|
||||
setattr(milestone, key, value)
|
||||
milestone.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(milestone)
|
||||
return milestone
|
||||
|
||||
|
||||
async def delete_milestone(user_id: int, milestone_id: int) -> bool:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
|
||||
)
|
||||
milestone = result.scalars().first()
|
||||
if milestone is None:
|
||||
return False
|
||||
await session.delete(milestone)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def get_milestone_progress(milestone_id: int) -> dict:
|
||||
"""Return task completion stats for a milestone."""
|
||||
async with async_session() as session:
|
||||
rows = await session.execute(
|
||||
select(Note.status, func.count(Note.id))
|
||||
.where(Note.milestone_id == milestone_id, Note.status.isnot(None))
|
||||
.group_by(Note.status)
|
||||
)
|
||||
status_counts: dict[str, int] = {}
|
||||
for status, count in rows.fetchall():
|
||||
status_counts[status] = count
|
||||
|
||||
total = sum(status_counts.values())
|
||||
completed = status_counts.get("done", 0)
|
||||
pct = round(completed / total * 100, 1) if total > 0 else 0.0
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"pct": pct,
|
||||
"status_counts": {
|
||||
"todo": status_counts.get("todo", 0),
|
||||
"in_progress": status_counts.get("in_progress", 0),
|
||||
"done": status_counts.get("done", 0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]:
|
||||
"""Return ordered list of milestones with their progress stats."""
|
||||
milestones = await list_milestones(user_id, project_id)
|
||||
result = []
|
||||
for m in milestones:
|
||||
progress = await get_milestone_progress(m.id)
|
||||
entry = m.to_dict()
|
||||
entry.update(progress)
|
||||
result.append(entry)
|
||||
return result
|
||||
@@ -9,12 +9,26 @@ from fabledassistant.models.note import Note, TaskPriority, TaskStatus
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_tags(tags: list[str]) -> list[str]:
|
||||
"""Lowercase, strip, deduplicate, and drop empty tags."""
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for t in tags:
|
||||
normalized = t.strip().lower()
|
||||
if normalized and normalized not in seen:
|
||||
seen.add(normalized)
|
||||
out.append(normalized)
|
||||
return out
|
||||
|
||||
|
||||
async def create_note(
|
||||
user_id: int,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
tags: list[str] | None = None,
|
||||
parent_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
milestone_id: int | None = None,
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
due_date: date | None = None,
|
||||
@@ -24,8 +38,10 @@ async def create_note(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
body=body,
|
||||
tags=tags or [],
|
||||
tags=_normalize_tags(tags or []),
|
||||
parent_id=parent_id,
|
||||
project_id=project_id,
|
||||
milestone_id=milestone_id,
|
||||
status=status,
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
@@ -53,6 +69,8 @@ async def list_notes(
|
||||
priority: str | None = None,
|
||||
due_before: date | None = None,
|
||||
due_after: date | None = None,
|
||||
project_id: int | None = None,
|
||||
milestone_id: int | None = None,
|
||||
sort: str = "updated_at",
|
||||
order: str = "desc",
|
||||
limit: int = 50,
|
||||
@@ -105,6 +123,14 @@ async def list_notes(
|
||||
query = query.where(Note.due_date >= due_after)
|
||||
count_query = count_query.where(Note.due_date >= due_after)
|
||||
|
||||
if project_id is not None:
|
||||
query = query.where(Note.project_id == project_id)
|
||||
count_query = count_query.where(Note.project_id == project_id)
|
||||
|
||||
if milestone_id is not None:
|
||||
query = query.where(Note.milestone_id == milestone_id)
|
||||
count_query = count_query.where(Note.milestone_id == milestone_id)
|
||||
|
||||
sort_col = getattr(Note, sort, Note.updated_at)
|
||||
if order == "asc":
|
||||
query = query.order_by(sort_col.asc())
|
||||
@@ -153,6 +179,8 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
|
||||
value = TaskStatus(value).value
|
||||
elif key == "priority" and isinstance(value, str):
|
||||
value = TaskPriority(value).value
|
||||
elif key == "tags" and isinstance(value, list):
|
||||
value = _normalize_tags(value)
|
||||
setattr(note, key, value)
|
||||
note.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Project management service."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.project import Project
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_project(
|
||||
user_id: int,
|
||||
title: str,
|
||||
description: str = "",
|
||||
goal: str = "",
|
||||
color: str | None = None,
|
||||
) -> Project:
|
||||
async with async_session() as session:
|
||||
project = Project(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
description=description,
|
||||
goal=goal,
|
||||
color=color,
|
||||
status="active",
|
||||
)
|
||||
session.add(project)
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
async def get_project(user_id: int, project_id: int) -> Project | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == user_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_project_by_title(user_id: int, title: str) -> Project | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Project).where(
|
||||
Project.user_id == user_id,
|
||||
func.lower(Project.title) == func.lower(title.strip()),
|
||||
).limit(1)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_or_create_project(user_id: int, title: str) -> Project:
|
||||
project = await get_project_by_title(user_id, title)
|
||||
if project:
|
||||
return project
|
||||
return await create_project(user_id, title=title)
|
||||
|
||||
|
||||
async def list_projects(user_id: int, status: str | None = None) -> list[Project]:
|
||||
async with async_session() as session:
|
||||
query = select(Project).where(Project.user_id == user_id)
|
||||
if status:
|
||||
query = query.where(Project.status == status)
|
||||
query = query.order_by(Project.updated_at.desc())
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_project(user_id: int, project_id: int, **fields: object) -> Project | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == user_id)
|
||||
)
|
||||
project = result.scalars().first()
|
||||
if project is None:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
if hasattr(project, key):
|
||||
setattr(project, key, value)
|
||||
project.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
async def delete_project(user_id: int, project_id: int) -> bool:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == user_id)
|
||||
)
|
||||
project = result.scalars().first()
|
||||
if project is None:
|
||||
return False
|
||||
# Unlink notes (cascade handled by DB ON DELETE SET NULL, but we delete project here)
|
||||
await session.delete(project)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def get_project_summary(user_id: int, project_id: int) -> dict:
|
||||
"""Return task counts by status, note count, and last activity."""
|
||||
async with async_session() as session:
|
||||
# Task counts by status
|
||||
task_rows = await session.execute(
|
||||
select(Note.status, func.count(Note.id))
|
||||
.where(
|
||||
Note.user_id == user_id,
|
||||
Note.project_id == project_id,
|
||||
Note.status.isnot(None),
|
||||
)
|
||||
.group_by(Note.status)
|
||||
)
|
||||
task_counts: dict[str, int] = {}
|
||||
for status, count in task_rows.fetchall():
|
||||
task_counts[status] = count
|
||||
|
||||
# Note count (non-tasks)
|
||||
note_count_result = await session.scalar(
|
||||
select(func.count(Note.id)).where(
|
||||
Note.user_id == user_id,
|
||||
Note.project_id == project_id,
|
||||
Note.status.is_(None),
|
||||
)
|
||||
)
|
||||
note_count = note_count_result or 0
|
||||
|
||||
# Last activity
|
||||
last_activity_result = await session.scalar(
|
||||
select(func.max(Note.updated_at)).where(
|
||||
Note.user_id == user_id,
|
||||
Note.project_id == project_id,
|
||||
)
|
||||
)
|
||||
|
||||
from fabledassistant.services.milestones import get_project_milestone_summary
|
||||
milestone_summary = await get_project_milestone_summary(user_id, project_id)
|
||||
|
||||
return {
|
||||
"task_counts": task_counts,
|
||||
"note_count": note_count,
|
||||
"last_activity": last_activity_result.isoformat() if last_activity_result else None,
|
||||
"milestone_summary": milestone_summary,
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Browser push notification service using VAPID/pywebpush."""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.push_subscription import PushSubscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_webpush():
|
||||
"""Lazy import to avoid startup errors if pywebpush is not installed."""
|
||||
try:
|
||||
from pywebpush import WebPusher
|
||||
return WebPusher
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def vapid_enabled() -> bool:
|
||||
return bool(Config.VAPID_PRIVATE_KEY and Config.VAPID_PUBLIC_KEY)
|
||||
|
||||
|
||||
async def save_subscription(user_id: int, subscription_json: dict, user_agent: str | None = None) -> PushSubscription:
|
||||
"""Upsert a push subscription by endpoint."""
|
||||
endpoint = subscription_json.get("endpoint", "")
|
||||
keys = subscription_json.get("keys", {})
|
||||
p256dh = keys.get("p256dh", "")
|
||||
auth = keys.get("auth", "")
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(PushSubscription).where(
|
||||
PushSubscription.user_id == user_id,
|
||||
PushSubscription.endpoint == endpoint,
|
||||
)
|
||||
)
|
||||
existing = result.scalars().first()
|
||||
if existing:
|
||||
existing.p256dh = p256dh
|
||||
existing.auth = auth
|
||||
existing.user_agent = user_agent
|
||||
existing.last_used = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
return existing
|
||||
else:
|
||||
sub = PushSubscription(
|
||||
user_id=user_id,
|
||||
endpoint=endpoint,
|
||||
p256dh=p256dh,
|
||||
auth=auth,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
session.add(sub)
|
||||
await session.commit()
|
||||
await session.refresh(sub)
|
||||
return sub
|
||||
|
||||
|
||||
async def delete_subscription(user_id: int, endpoint: str) -> None:
|
||||
async with async_session() as session:
|
||||
await session.execute(
|
||||
delete(PushSubscription).where(
|
||||
PushSubscription.user_id == user_id,
|
||||
PushSubscription.endpoint == endpoint,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _remove_expired_subscription(user_id: int, endpoint: str) -> None:
|
||||
"""Remove a subscription that returned 410 Gone."""
|
||||
try:
|
||||
await delete_subscription(user_id, endpoint)
|
||||
logger.info("Removed expired push subscription for user %d", user_id)
|
||||
except Exception:
|
||||
logger.warning("Failed to remove expired subscription", exc_info=True)
|
||||
|
||||
|
||||
async def send_push_notification(
|
||||
user_id: int,
|
||||
title: str,
|
||||
body: str,
|
||||
url: str = "/",
|
||||
) -> None:
|
||||
"""Send a push notification to all subscriptions for a user.
|
||||
|
||||
Fire-and-forget — wrap in asyncio.create_task() at call site.
|
||||
"""
|
||||
if not vapid_enabled():
|
||||
logger.debug("VAPID not configured, skipping push notification")
|
||||
return
|
||||
|
||||
WebPusher = _get_webpush()
|
||||
if WebPusher is None:
|
||||
logger.warning("pywebpush not installed, cannot send push notifications")
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(PushSubscription).where(PushSubscription.user_id == user_id)
|
||||
)
|
||||
subscriptions = list(result.scalars().all())
|
||||
|
||||
if not subscriptions:
|
||||
return
|
||||
|
||||
payload = json.dumps({"title": title, "body": body, "url": url})
|
||||
vapid_claims = {
|
||||
"sub": Config.VAPID_CLAIMS_SUB,
|
||||
}
|
||||
|
||||
def _send_sync(sub: PushSubscription) -> tuple[int, str]:
|
||||
"""Synchronous send — runs in executor."""
|
||||
try:
|
||||
subscription_info = {
|
||||
"endpoint": sub.endpoint,
|
||||
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
|
||||
}
|
||||
response = WebPusher(subscription_info).send(
|
||||
data=payload,
|
||||
vapid_private_key=Config.VAPID_PRIVATE_KEY,
|
||||
vapid_claims=vapid_claims,
|
||||
)
|
||||
return response.status_code, sub.endpoint
|
||||
except Exception as e:
|
||||
logger.warning("Push send failed for sub %d: %s", sub.id, e)
|
||||
return 0, sub.endpoint
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
tasks = [loop.run_in_executor(None, _send_sync, sub) for sub in subscriptions]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for sub, result in zip(subscriptions, results):
|
||||
if isinstance(result, Exception):
|
||||
continue
|
||||
status_code, endpoint = result
|
||||
if status_code == 410:
|
||||
asyncio.create_task(_remove_expired_subscription(user_id, endpoint))
|
||||
elif status_code and status_code not in (200, 201):
|
||||
logger.warning("Push returned status %d for user %d", status_code, user_id)
|
||||
@@ -55,6 +55,18 @@ _CORE_TOOLS = [
|
||||
"enum": ["none", "low", "medium", "high"],
|
||||
"description": "Task priority level",
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Optional project name to assign this task to",
|
||||
},
|
||||
"parent_task": {
|
||||
"type": "string",
|
||||
"description": "Optional title of a parent task to make this a sub-task of",
|
||||
},
|
||||
"milestone": {
|
||||
"type": "string",
|
||||
"description": "Optional milestone title within the project to assign this task to",
|
||||
},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
@@ -81,6 +93,10 @@ _CORE_TOOLS = [
|
||||
"items": {"type": "string"},
|
||||
"description": "Tags for the note (without # prefix, hyphens for multi-word: [\"science-fiction\", \"story/idea\"]). Do NOT embed #tags in the note body.",
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Optional project name to assign this note to",
|
||||
},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
@@ -143,6 +159,10 @@ _CORE_TOOLS = [
|
||||
"enum": ["replace", "add", "remove"],
|
||||
"description": "'replace' sets tags to exactly this list, 'add' appends without duplicates, 'remove' removes listed tags (default: replace)",
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Optional project name to assign this note/task to (set to empty string to remove project)",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
@@ -294,6 +314,14 @@ _CORE_TOOLS = [
|
||||
"type": "string",
|
||||
"description": "Return tasks due on or after this date (YYYY-MM-DD)",
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Filter tasks by project name",
|
||||
},
|
||||
"milestone": {
|
||||
"type": "string",
|
||||
"description": "Filter tasks by milestone name within a project",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of tasks to return (default 10)",
|
||||
@@ -302,6 +330,100 @@ _CORE_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_project",
|
||||
"description": "Create a new project. Use when the user asks to create a project, initiative, or campaign to organize related tasks and notes.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "Project title"},
|
||||
"description": {"type": "string", "description": "Brief description"},
|
||||
"goal": {"type": "string", "description": "The goal or desired outcome"},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_projects",
|
||||
"description": "List the user's projects. Use when asked about projects, initiatives, or campaigns.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["active", "completed", "archived"],
|
||||
"description": "Filter by status (omit for all)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_project",
|
||||
"description": "Get details and summary of a specific project.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Project name or keyword to find it"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_project",
|
||||
"description": "Update a project's status, description, or goal.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Project name to find"},
|
||||
"status": {"type": "string", "enum": ["active", "completed", "archived"]},
|
||||
"description": {"type": "string"},
|
||||
"goal": {"type": "string"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_milestone",
|
||||
"description": "Create a new milestone inside a project for grouping and tracking related tasks.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project": {"type": "string", "description": "Project title (will be created if not found)"},
|
||||
"title": {"type": "string", "description": "Milestone title"},
|
||||
"description": {"type": "string", "description": "Optional description"},
|
||||
},
|
||||
"required": ["project", "title"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_milestones",
|
||||
"description": "List milestones for a project with their progress percentages.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project": {"type": "string", "description": "Project name to look up"},
|
||||
},
|
||||
"required": ["project"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# CalDAV tools — only included when user has CalDAV configured
|
||||
@@ -604,6 +726,27 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
)
|
||||
suggested = await suggest_tags(user_id, task_title, task_body)
|
||||
_schedule_embedding(note.id, user_id, task_title, task_body)
|
||||
# Handle optional project and parent_task assignment
|
||||
project_name = arguments.get("project")
|
||||
parent_task_name = arguments.get("parent_task")
|
||||
update_fields: dict = {}
|
||||
if project_name:
|
||||
from fabledassistant.services.projects import get_or_create_project
|
||||
proj = await get_or_create_project(user_id, project_name)
|
||||
update_fields["project_id"] = proj.id
|
||||
if parent_task_name:
|
||||
parent_notes, _ = await list_notes(user_id=user_id, q=parent_task_name, is_task=True, limit=1)
|
||||
if parent_notes:
|
||||
update_fields["parent_id"] = parent_notes[0].id
|
||||
milestone_name = arguments.get("milestone")
|
||||
if milestone_name and project_name:
|
||||
from fabledassistant.services.projects import get_or_create_project as _gocp
|
||||
from fabledassistant.services.milestones import get_or_create_milestone
|
||||
proj = await _gocp(user_id, project_name)
|
||||
ms = await get_or_create_milestone(user_id, proj.id, milestone_name)
|
||||
update_fields["milestone_id"] = ms.id
|
||||
if update_fields:
|
||||
note = await update_note(user_id, note.id, **update_fields)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "task",
|
||||
@@ -613,6 +756,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
"status": note.status,
|
||||
"priority": note.priority,
|
||||
"due_date": str(note.due_date) if note.due_date else None,
|
||||
"project_id": note.project_id,
|
||||
},
|
||||
"suggested_tags": suggested,
|
||||
}
|
||||
@@ -635,12 +779,19 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
)
|
||||
suggested = await suggest_tags(user_id, note_title, note_body)
|
||||
_schedule_embedding(note.id, user_id, note_title, note_body)
|
||||
# Handle optional project assignment
|
||||
project_name = arguments.get("project")
|
||||
if project_name:
|
||||
from fabledassistant.services.projects import get_or_create_project
|
||||
proj = await get_or_create_project(user_id, project_name)
|
||||
note = await update_note(user_id, note.id, project_id=proj.id)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "note",
|
||||
"data": {
|
||||
"id": note.id,
|
||||
"title": note.title,
|
||||
"project_id": note.project_id,
|
||||
},
|
||||
"suggested_tags": suggested,
|
||||
}
|
||||
@@ -693,6 +844,15 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
else:
|
||||
update_fields["tags"] = new_tags
|
||||
|
||||
if "project" in arguments:
|
||||
project_name = arguments["project"]
|
||||
if project_name:
|
||||
from fabledassistant.services.projects import get_or_create_project
|
||||
proj = await get_or_create_project(user_id, project_name)
|
||||
update_fields["project_id"] = proj.id
|
||||
else:
|
||||
update_fields["project_id"] = None
|
||||
|
||||
updated = await update_note(user_id, note.id, **update_fields)
|
||||
if updated is None:
|
||||
return {"success": False, "error": "Failed to update note."}
|
||||
@@ -710,6 +870,25 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
}
|
||||
|
||||
elif tool_name == "list_tasks":
|
||||
project_id = None
|
||||
milestone_id = None
|
||||
project_name = arguments.get("project")
|
||||
if project_name:
|
||||
from fabledassistant.services.projects import get_project_by_title, list_projects as _lp
|
||||
proj = await get_project_by_title(user_id, project_name)
|
||||
if proj is None:
|
||||
all_p = await _lp(user_id)
|
||||
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
|
||||
if matches:
|
||||
proj = matches[0]
|
||||
if proj:
|
||||
project_id = proj.id
|
||||
milestone_name = arguments.get("milestone")
|
||||
if milestone_name:
|
||||
from fabledassistant.services.milestones import get_milestone_by_title
|
||||
ms = await get_milestone_by_title(user_id, proj.id, milestone_name)
|
||||
if ms:
|
||||
milestone_id = ms.id
|
||||
notes, total = await list_notes(
|
||||
user_id=user_id,
|
||||
is_task=True,
|
||||
@@ -717,6 +896,8 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
priority=arguments.get("priority"),
|
||||
due_before=_parse_due_date(arguments.get("due_before")),
|
||||
due_after=_parse_due_date(arguments.get("due_after")),
|
||||
project_id=project_id,
|
||||
milestone_id=milestone_id,
|
||||
limit=int(arguments.get("limit", 10)),
|
||||
sort="due_date",
|
||||
order="asc",
|
||||
@@ -729,6 +910,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
"status": n.status,
|
||||
"priority": n.priority,
|
||||
"due_date": str(n.due_date) if n.due_date else None,
|
||||
"project_id": n.project_id,
|
||||
"preview": (n.body[:120] + "...") if n.body and len(n.body) > 120 else (n.body or ""),
|
||||
})
|
||||
return {
|
||||
@@ -950,6 +1132,39 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
"data": {"id": note.id, "title": note.title},
|
||||
}
|
||||
|
||||
elif tool_name == "create_milestone":
|
||||
from fabledassistant.services.projects import get_or_create_project as _gocp
|
||||
from fabledassistant.services.milestones import create_milestone as _cm, get_project_milestone_summary
|
||||
project_name = arguments.get("project", "")
|
||||
if not project_name:
|
||||
return {"success": False, "error": "project is required"}
|
||||
proj = await _gocp(user_id, project_name)
|
||||
ms = await _cm(
|
||||
user_id,
|
||||
proj.id,
|
||||
title=arguments.get("title", "Untitled Milestone"),
|
||||
description=arguments.get("description"),
|
||||
)
|
||||
return {"success": True, "type": "milestone", "data": ms.to_dict()}
|
||||
|
||||
elif tool_name == "list_milestones":
|
||||
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
|
||||
from fabledassistant.services.milestones import get_project_milestone_summary
|
||||
project_name = arguments.get("project", "")
|
||||
proj = await _gpbt(user_id, project_name)
|
||||
if proj is None:
|
||||
all_p = await _lp(user_id)
|
||||
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
|
||||
proj = matches[0] if matches else None
|
||||
if proj is None:
|
||||
return {"success": False, "error": f"No project found matching '{project_name}'"}
|
||||
summary = await get_project_milestone_summary(user_id, proj.id)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "milestones_list",
|
||||
"data": {"project": proj.title, "count": len(summary), "milestones": summary},
|
||||
}
|
||||
|
||||
elif tool_name == "search_web":
|
||||
from fabledassistant.services.research import _search_searxng
|
||||
query = arguments.get("query", "")
|
||||
@@ -976,6 +1191,67 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
"data": {"topic": topic},
|
||||
}
|
||||
|
||||
elif tool_name == "create_project":
|
||||
from fabledassistant.services.projects import create_project as _create_project
|
||||
project = await _create_project(
|
||||
user_id,
|
||||
title=arguments.get("title", "New Project"),
|
||||
description=arguments.get("description", ""),
|
||||
goal=arguments.get("goal", ""),
|
||||
)
|
||||
return {"success": True, "type": "project", "data": project.to_dict()}
|
||||
|
||||
elif tool_name == "list_projects":
|
||||
from fabledassistant.services.projects import list_projects as _list_projects
|
||||
projects = await _list_projects(user_id, status=arguments.get("status"))
|
||||
return {
|
||||
"success": True,
|
||||
"type": "projects_list",
|
||||
"data": {"count": len(projects), "projects": [p.to_dict() for p in projects]},
|
||||
}
|
||||
|
||||
elif tool_name == "get_project":
|
||||
from fabledassistant.services.projects import (
|
||||
get_project_by_title as _gpbt,
|
||||
get_project_summary as _gps,
|
||||
list_projects as _lp,
|
||||
)
|
||||
query = arguments.get("query", "")
|
||||
project = await _gpbt(user_id, query)
|
||||
if project is None:
|
||||
all_projects = await _lp(user_id)
|
||||
matches = [p for p in all_projects if query.lower() in p.title.lower()]
|
||||
if matches:
|
||||
project = matches[0]
|
||||
if project is None:
|
||||
return {"success": False, "error": f"No project found matching '{query}'"}
|
||||
summary = await _gps(user_id, project.id)
|
||||
data = project.to_dict()
|
||||
data["summary"] = summary
|
||||
return {"success": True, "type": "project_detail", "data": data}
|
||||
|
||||
elif tool_name == "update_project":
|
||||
from fabledassistant.services.projects import (
|
||||
get_project_by_title as _gpbt,
|
||||
update_project as _up,
|
||||
list_projects as _lp,
|
||||
)
|
||||
query = arguments.get("query", "")
|
||||
project = await _gpbt(user_id, query)
|
||||
if project is None:
|
||||
all_projects = await _lp(user_id)
|
||||
matches = [p for p in all_projects if query.lower() in p.title.lower()]
|
||||
if matches:
|
||||
project = matches[0]
|
||||
if project is None:
|
||||
return {"success": False, "error": f"No project found matching '{query}'"}
|
||||
fields = {}
|
||||
for k in ("status", "description", "goal"):
|
||||
if k in arguments:
|
||||
fields[k] = arguments[k]
|
||||
updated = await _up(user_id, project.id, **fields)
|
||||
return {"success": True, "type": "project_updated", "data": updated.to_dict()}
|
||||
|
||||
elif tool_name == "search_images":
|
||||
from fabledassistant.services.images import fetch_and_store_image
|
||||
from fabledassistant.services.research import _search_searxng_images
|
||||
|
||||
+124
-2
@@ -12,7 +12,7 @@
|
||||
> Include file-level details in the commit body when the change is non-trivial.
|
||||
|
||||
## Last Updated
|
||||
2026-03-01 — Quick-capture overhaul (dedicated classifier, research + update_note support); CalDAV todo tools removed; run_research_pipeline buf optional
|
||||
2026-03-02 — Projects + Milestones hierarchy; RAG auto-injection; summarization improvements; browser push notifications; PWA manifest; Radicale CalDAV reverted to external; Flutter Android companion app updated
|
||||
|
||||
## Project Overview
|
||||
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
||||
@@ -757,6 +757,47 @@ When adding a new migration, follow these conventions:
|
||||
pills in ToolCallCard with one-click apply via append-tag API.
|
||||
LLM instructed to use the `tags` parameter, not embed `#tag` text in note body.
|
||||
|
||||
### Projects & Milestones (Phases A + G)
|
||||
- **Project model** (`models/project.py`): id, user_id (FK CASCADE), title, description, goal, status ("active"/"completed"/"archived"), color, timestamps.
|
||||
- **Milestone model** (`models/milestone.py`): id, user_id, project_id (FK CASCADE), title, description, status, order_index, timestamps. Hierarchy: **Project → Milestone → Task**.
|
||||
- **`notes` table**: `project_id` FK (SET NULL) + `milestone_id` FK (SET NULL) columns. `parent_id` column now has a FK constraint to `notes.id` (SET NULL) to enable sub-tasks.
|
||||
- **Migrations**: `0017_add_projects.py`, `0020_add_milestones.py` (milestone_id on notes).
|
||||
- **Services**: `services/projects.py` (CRUD + `get_or_create_project` + `get_project_summary`); `services/milestones.py` (CRUD + `get_milestone_progress` + `get_project_milestone_summary`).
|
||||
- **Routes**: `routes/projects.py` under `/api/projects`; `routes/milestones.py` under `/api/projects/<pid>/milestones`.
|
||||
- **LLM tools**: `create_project`, `list_projects`, `get_project`, `update_project`; `create_milestone`, `list_milestones`; `create_note`/`create_task`/`update_note` accept optional `project` + `milestone` + `parent_task` params.
|
||||
- **Frontend**: `ProjectListView.vue` (card grid, stacked milestone progress bars per card), `ProjectView.vue` (milestone-grouped kanban + inline milestone management), `ProjectSelector.vue` (dropdown used in NoteEditorView + TaskEditorView), `MilestoneSelector.vue` (used in TaskEditorView).
|
||||
- **Types**: `frontend/src/types/note.ts` Note interface now includes `project_id: number | null` and `milestone_id: number | null`.
|
||||
- `app.py` registers `projects_bp` and `milestones_bp`; router adds `/projects` and `/projects/:id`.
|
||||
|
||||
### RAG Auto-injection (Phase B)
|
||||
- Notes scoring ≥0.60 cosine similarity are injected automatically into every chat system prompt (max 3, up to 800 chars each) under `--- Relevant Notes ---` heading.
|
||||
- `semantic_search_notes()` accepts `threshold` param (default 0.45 for sidebar suggestions; 0.60 for auto-inject).
|
||||
- `build_context()` constants: `RAG_AUTO_THRESHOLD=0.60`, `RAG_AUTO_LIMIT=3`, `RAG_AUTO_SNIPPET=800`.
|
||||
- `context_meta` adds `auto_injected_notes` list + `auto_injected: bool` flag on each `auto_notes` item.
|
||||
- `excluded_note_ids` param in `build_context()` + POST body lets users remove a note from auto-injection.
|
||||
- ChatView sidebar: "Auto-included" section (green border) above "Suggested" and "In Context".
|
||||
|
||||
### Summarization Improvements (Phase C)
|
||||
- `_HISTORY_SUMMARY_THRESHOLD` raised 20 → 30; `_HISTORY_KEEP_RECENT` raised 6 → 8; summary `max_tokens` raised 200 → 400.
|
||||
- Improved summary prompt captures: note/task/project names, decisions, open questions, topic arc.
|
||||
- Two-pass summarization for histories >50 messages (first half → summary_a, combined → final).
|
||||
|
||||
### Browser Push Notifications (Phase E)
|
||||
- **`models/push_subscription.py`**: PushSubscription — endpoint, p256dh, auth, user_agent; UNIQUE(user_id, endpoint).
|
||||
- **Migration**: `0018_add_push_subscriptions.py` (down_revision="0017").
|
||||
- **`services/push.py`**: `vapid_enabled()`, `save_subscription()`, `delete_subscription()`, `send_push_notification()` (lazy-imports pywebpush, fire-and-forget, removes 410 Gone subs).
|
||||
- **`routes/push.py`**: `GET /api/push/vapid-public-key`, `POST/DELETE /api/push/subscribe`.
|
||||
- Config: `VAPID_PRIVATE_KEY`, `VAPID_PUBLIC_KEY`, `VAPID_CLAIMS_SUB` env vars.
|
||||
- `generation_task.py`: fires push after `buf.state = COMPLETED` (guarded by `vapid_enabled()`).
|
||||
- `pywebpush>=2.0` added to `pyproject.toml`.
|
||||
- **Frontend**: `frontend/public/sw.js` (push + notificationclick handlers); `frontend/src/stores/push.ts` (isSupported, permission, isSubscribed, subscribe/unsubscribe); SettingsView "Push Notifications" toggle.
|
||||
|
||||
### PWA Manifest (Phase F)
|
||||
- `frontend/public/manifest.json`: name, short_name, start_url, display: standalone, icons.
|
||||
- `index.html`: `<link rel="manifest">`, `<meta name="theme-color">`, Apple mobile meta tags.
|
||||
- `main.ts`: service worker registration.
|
||||
- Installable on iOS (Share → Add to Home Screen) and Android. Push works on Android (iOS 16.4+ partial).
|
||||
|
||||
### Authentication & User Management
|
||||
- Session cookie auth with bcrypt, first-user-is-admin, orphaned data claiming
|
||||
- Per-user data isolation across all resources
|
||||
@@ -813,7 +854,7 @@ When adding a new migration, follow these conventions:
|
||||
- Security headers applied in `after_request`: `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Content-Security-Policy`
|
||||
- In-memory sliding-window rate limiter on all auth endpoints (login, register, forgot/reset password); proxy-aware client IP with `TRUST_PROXY_HEADERS`
|
||||
- SQLAlchemy async engine configured with `pool_pre_ping=True` (tests connections before use, discards stale ones after Postgres restart) and `pool_recycle=1800` (recycles idle connections every 30 min to prevent TCP/firewall staleness)
|
||||
- Alembic migrations: 15 migrations, all raw SQL with idempotency guards (IF NOT EXISTS, DO $$ BEGIN...EXCEPTION)
|
||||
- Alembic migrations: 20 migrations (0001–0020), all raw SQL with idempotency guards (IF NOT EXISTS, DO $$ BEGIN...EXCEPTION)
|
||||
- Config from env vars + Docker secrets file support (`_read_secret`)
|
||||
|
||||
## Development Workflow
|
||||
@@ -824,6 +865,83 @@ When adding a new migration, follow these conventions:
|
||||
Quart serves them
|
||||
- To reset database: `docker compose down -v && docker compose up --build`
|
||||
|
||||
## Android Companion App
|
||||
|
||||
A native Flutter app at `/home/bvandeusen/Nextcloud/Projects/fabled_app` (separate repository).
|
||||
|
||||
### Stack
|
||||
- Flutter + Dart, Riverpod (state), GoRouter (navigation), Dio (HTTP), PersistCookieJar (session)
|
||||
- SSE streaming for chat using `fetch` + `ReadableStream` bridge
|
||||
- Self-update support via Forgejo release API (`update_provider.dart`)
|
||||
|
||||
### Architecture
|
||||
- **Data layer**: `lib/data/models/`, `lib/data/api/`, `lib/data/repositories/`
|
||||
- **State**: Riverpod `AsyncNotifierProvider` per resource in `lib/providers/`
|
||||
- **Navigation**: GoRouter with `ShellRoute` for tabbed main shell; `_RouterNotifier` refreshes on auth change
|
||||
|
||||
### Models & API Coverage
|
||||
|
||||
| Resource | Model fields | API endpoints |
|
||||
|----------|-------------|---------------|
|
||||
| Note | id, title, body, tags, project_id, milestone_id, created_at, updated_at | GET/POST /api/notes, GET/PUT/DELETE /api/notes/:id |
|
||||
| Task | id, title, body, status, priority, due_date, project_id, milestone_id, parent_id, created_at, updated_at | GET/POST /api/tasks, GET/PUT/DELETE /api/tasks/:id |
|
||||
| Project | id, title, description, goal, status, color, created_at, updated_at | GET/POST /api/projects, GET/PATCH/DELETE /api/projects/:id |
|
||||
| Conversation | standard chat fields | GET/POST conversations, SSE stream |
|
||||
| QuickCapture | text → type+title+message | POST /api/quick-capture |
|
||||
|
||||
### Navigation Shell (4 tabs)
|
||||
Notes · Tasks · Projects · Chat — bottom `NavigationBar` on phone; `NavigationRail` on tablet/landscape.
|
||||
Quick Capture bar at the top of the shell (persists across all tabs); Settings accessible from top-right icon.
|
||||
|
||||
### Feature Status
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Notes CRUD | ✅ | tags chip input; project selector in editor |
|
||||
| Tasks CRUD | ✅ | project selector in editor |
|
||||
| Projects list | ✅ | active/archived sections; long-press status change; create dialog |
|
||||
| Chat + SSE | ✅ | full streaming |
|
||||
| Quick Capture | ✅ | offline queue with retry |
|
||||
| Tags | ✅ (model + API) | Chip input in NoteEditScreen; typed as `List<String>` |
|
||||
| Project assignment | ✅ | `ProjectSelector` dropdown in Note + Task editors |
|
||||
| Milestones | ❌ deferred | Too granular for mobile; web UI handles it |
|
||||
| Push notifications | ❌ incompatible | Backend uses browser VAPID; Flutter needs FCM/APNs — separate implementation required |
|
||||
| CalDAV settings | ❌ intentional | Server-side config only; not exposed in mobile app |
|
||||
|
||||
### Key Files
|
||||
```
|
||||
lib/
|
||||
app.dart # GoRouter + _Shell + _QuickCaptureBar
|
||||
core/constants.dart # Routes.*
|
||||
data/
|
||||
models/note.dart # Note (tags, project_id, milestone_id)
|
||||
models/task.dart # Task (project_id, milestone_id, parent_id)
|
||||
models/project.dart # Project
|
||||
api/notes_api.dart # create/update accept tags, project_id
|
||||
api/tasks_api.dart # create accepts project_id
|
||||
api/projects_api.dart # full CRUD
|
||||
repositories/notes_repository.dart
|
||||
repositories/tasks_repository.dart
|
||||
repositories/projects_repository.dart
|
||||
providers/
|
||||
api_client_provider.dart # all API + repository providers
|
||||
notes_provider.dart # NotesNotifier
|
||||
tasks_provider.dart # TasksNotifier
|
||||
projects_provider.dart # ProjectsNotifier
|
||||
screens/
|
||||
notes/note_edit_screen.dart # chip tag input + ProjectSelector
|
||||
tasks/task_edit_screen.dart # ProjectSelector
|
||||
projects/project_list_screen.dart
|
||||
widgets/
|
||||
project_selector.dart # reusable DropdownButtonFormField
|
||||
```
|
||||
|
||||
### API Compatibility Notes
|
||||
- `GET /api/projects/:id` returns a flat JSON object (not `{project: ...}` wrapper); includes `summary` field.
|
||||
- `POST /api/projects` returns project dict directly (201).
|
||||
- `PATCH /api/projects/:id` returns updated project dict.
|
||||
- Task body field name is `body` (not `description`) — Flutter maps `description` → `body` on serialize.
|
||||
|
||||
## Backlog
|
||||
- Calendar/timeline view for tasks
|
||||
- Import/export (Markdown files, JSON)
|
||||
@@ -833,3 +951,7 @@ When adding a new migration, follow these conventions:
|
||||
notes via wikilinks (`[[Title]]`) and shared tags. Nodes = notes/tasks; edges = wikilink references or
|
||||
tag co-occurrence. Clicking a node navigates to that note. Filter by tag or depth. Useful for discovering
|
||||
clusters of related notes and orphaned notes with no connections.
|
||||
- **Flutter push notifications:** Requires FCM/APNs integration (separate from web VAPID). Would need
|
||||
a FCM server key in Config and a new notification pathway in `generation_task.py`.
|
||||
- **Flutter milestone support:** Milestone grouping in project view deferred; could add as a filtered
|
||||
task list view once the feature is well-established on web.
|
||||
|
||||
Reference in New Issue
Block a user