Compare commits
5 Commits
dev
..
03772ff424
| Author | SHA1 | Date | |
|---|---|---|---|
| 03772ff424 | |||
| 058e8794af | |||
| f8c58a7f0f | |||
| 6cdac307af | |||
| d324205450 |
@@ -1,10 +1,10 @@
|
||||
# Fabled Scribe
|
||||
|
||||
A self-hosted work system-of-record for software projects, built to be driven by Claude Code. Notes, tasks, issues, projects, milestones, rules, and stored processes — reachable from Claude via a built-in MCP endpoint and a bundled Claude Code plugin, with a clean web UI for humans. No in-app LLM; Claude is the sole assistant.
|
||||
A self-hosted second brain and project management application with integrated LLM capabilities. Write, organise, and act on your notes and tasks with the help of a local AI assistant — all running on your own hardware.
|
||||
|
||||
## Features
|
||||
|
||||
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system, and semantic search with proactive knowledge-injection into Claude's context. A knowledge graph, per-user/group sharing, and a built-in MCP server (`/mcp`) plus a bundled Claude Code plugin so Claude can record and recall your work directly.
|
||||
Notes and tasks with a Markdown editor, sub-tasks, milestones, and kanban project workspaces. AI chat with streaming responses, RAG over your notes, and tool use (web search, calendar, weather). A daily briefing that digests your tasks, RSS feeds, and weather on a schedule. Knowledge graph, per-user/group sharing, PWA with push notifications, and an MCP server for external AI clients.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
"""drop events table + notes.metadata column (retire calendar + entity surfaces)
|
||||
|
||||
Revision ID: 0069
|
||||
Revises: 0068
|
||||
Create Date: 2026-07-19
|
||||
|
||||
The personal-assistant surfaces (calendar/events + CalDAV, and the typed
|
||||
person/place/list entities that stored structured fields in notes.metadata)
|
||||
were removed when Scribe narrowed to a Claude-Code work system-of-record.
|
||||
This migration drops their storage:
|
||||
|
||||
- the `events` table (all calendar/CalDAV data)
|
||||
- the `notes.metadata` (entity_meta) JSONB column — it only ever held
|
||||
person/place/list structured fields. The `note_type` column STAYS: it
|
||||
also distinguishes 'process' notes.
|
||||
- orphan CalDAV settings rows (nothing reads them after the removal)
|
||||
|
||||
Downgrade recreates the table + column structure at its pre-removal shape.
|
||||
The dropped data itself is not recoverable.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "0069"
|
||||
down_revision = "0068"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Entity metadata column (person/place/list structured fields). The
|
||||
# note_type column is intentionally kept — it also marks 'process' notes.
|
||||
op.drop_column("notes", "metadata")
|
||||
|
||||
# Calendar / CalDAV storage. Dropping the table drops its indexes + the
|
||||
# duration CHECK constraint with it.
|
||||
op.drop_table("events")
|
||||
|
||||
# Orphan CalDAV integration settings — no code reads them post-removal.
|
||||
op.execute("DELETE FROM settings WHERE key LIKE 'caldav%'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate the events table at its pre-removal schema (empty — the data is
|
||||
# gone). Mirrors the model as of 0037 (reminders) + 0043 (duration_minutes)
|
||||
# + 0057 (soft-delete columns/index).
|
||||
op.create_table(
|
||||
"events",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("project_id", sa.Integer(),
|
||||
sa.ForeignKey("projects.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("uid", sa.Text(), nullable=False),
|
||||
sa.Column("title", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("start_dt", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("duration_minutes", sa.Integer(), nullable=True),
|
||||
sa.Column("all_day", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("description", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("location", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("caldav_uid", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("color", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("recurrence", sa.Text(), nullable=True),
|
||||
sa.Column("reminder_minutes", sa.Integer(), nullable=True),
|
||||
sa.Column("reminder_sent_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True),
|
||||
nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True),
|
||||
nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
|
||||
sa.CheckConstraint(
|
||||
"duration_minutes IS NULL OR duration_minutes >= 0",
|
||||
name="events_duration_minutes_non_negative",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_events_deleted_at", "events", ["deleted_at"])
|
||||
|
||||
# Re-add the entity metadata column.
|
||||
op.add_column("notes", sa.Column("metadata", JSONB(), nullable=True))
|
||||
@@ -86,7 +86,8 @@ table here. The tools are grouped by family:
|
||||
| Tasks | `create_task`, `update_task`, `add_task_log`, `start_planning` | Actionable work + plans |
|
||||
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
|
||||
| Search / Recall | `search`, `get_recent`, `list_tags` | Semantic + structured recall |
|
||||
| Systems | `create_system`, `list_systems`, `list_system_records` | Reusable per-project subsystems/areas |
|
||||
| Typed entities | `create_person`, `create_place`, `create_list`, … | Structured records |
|
||||
| Events | `create_event`, `list_events`, `update_event`, … | Calendar |
|
||||
| Rulebooks | `list_always_on_rules`, `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules |
|
||||
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
|
||||
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
|
||||
|
||||
@@ -81,6 +81,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
case "p": router.push("/projects"); break;
|
||||
case "r": router.push("/rules"); break;
|
||||
case "g": router.push("/graph"); break;
|
||||
case "l": router.push("/calendar"); break;
|
||||
case "x": router.push("/trash"); break;
|
||||
}
|
||||
return;
|
||||
@@ -189,6 +190,12 @@ onUnmounted(() => {
|
||||
<kbd class="shortcut-key">p</kbd>
|
||||
<span class="shortcut-desc">Projects</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">g</kbd>
|
||||
<span class="shortcut-key-sep">+</span>
|
||||
<kbd class="shortcut-key">l</kbd>
|
||||
<span class="shortcut-desc">Calendar</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">g</kbd>
|
||||
<span class="shortcut-key-sep">+</span>
|
||||
|
||||
@@ -374,6 +374,72 @@ export async function apiStreamPost(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Calendar events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EventEntry {
|
||||
id: number;
|
||||
uid: string;
|
||||
title: string;
|
||||
start_dt: string;
|
||||
end_dt: string | null;
|
||||
all_day: boolean;
|
||||
description: string;
|
||||
location: string;
|
||||
color: string;
|
||||
recurrence: string | null;
|
||||
caldav_uid: string;
|
||||
project_id: number | null;
|
||||
user_id: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface EventCreatePayload {
|
||||
title: string;
|
||||
start_dt: string;
|
||||
end_dt?: string;
|
||||
all_day?: boolean;
|
||||
description?: string;
|
||||
location?: string;
|
||||
color?: string;
|
||||
recurrence?: string;
|
||||
project_id?: number;
|
||||
}
|
||||
|
||||
export interface EventUpdatePayload {
|
||||
title?: string;
|
||||
start_dt?: string;
|
||||
end_dt?: string;
|
||||
all_day?: boolean;
|
||||
description?: string;
|
||||
location?: string;
|
||||
color?: string;
|
||||
recurrence?: string | null;
|
||||
project_id?: number;
|
||||
}
|
||||
|
||||
export async function listEvents(from: string, to: string): Promise<EventEntry[]> {
|
||||
return apiGet<EventEntry[]>(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
||||
}
|
||||
|
||||
export async function createEvent(payload: EventCreatePayload): Promise<EventEntry> {
|
||||
return apiPost<EventEntry>('/api/events', payload);
|
||||
}
|
||||
|
||||
export async function getEvent(id: number): Promise<EventEntry> {
|
||||
return apiGet<EventEntry>(`/api/events/${id}`);
|
||||
}
|
||||
|
||||
export async function updateEvent(id: number, payload: EventUpdatePayload): Promise<EventEntry> {
|
||||
return apiPatch<EventEntry>(`/api/events/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteEvent(id: number): Promise<void> {
|
||||
return apiDelete(`/api/events/${id}`);
|
||||
}
|
||||
|
||||
// ─── API Keys ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ApiKeyEntry {
|
||||
|
||||
@@ -47,6 +47,7 @@ router.afterEach(() => {
|
||||
<div class="nav-pill-bar">
|
||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
</div>
|
||||
@@ -92,6 +93,7 @@ router.afterEach(() => {
|
||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
<script setup lang="ts">
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const props = defineProps<{
|
||||
// null = create mode; EventEntry = edit mode
|
||||
event: EventEntry | null;
|
||||
// pre-filled date string for create mode (YYYY-MM-DD or ISO)
|
||||
initialDate?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "created", event: EventEntry): void;
|
||||
(e: "updated", event: EventEntry): void;
|
||||
(e: "deleted", id: number): void;
|
||||
}>();
|
||||
|
||||
const toast = useToastStore();
|
||||
|
||||
const isEditMode = computed(() => !!props.event);
|
||||
const saving = ref(false);
|
||||
const deleting = ref(false);
|
||||
const deleteConfirm = ref(false);
|
||||
|
||||
// Form fields
|
||||
const title = ref("");
|
||||
const startDate = ref("");
|
||||
const startTime = ref("");
|
||||
const endDate = ref("");
|
||||
const endTime = ref("");
|
||||
const allDay = ref(false);
|
||||
const description = ref("");
|
||||
const location = ref("");
|
||||
const color = ref("");
|
||||
const projectId = ref<number | null>(null);
|
||||
const recurrence = ref<string>("");
|
||||
|
||||
// Preset RRULE strings. The select binds to `recurrencePreset`, which writes
|
||||
// through to `recurrence`. CalDAV-imported rules with extra parts
|
||||
// (e.g. `FREQ=WEEKLY;BYDAY=MO,WE,FR`) fall through to "custom" and the raw
|
||||
// string is shown read-only below the select.
|
||||
const RECURRENCE_PRESETS: Record<string, string> = {
|
||||
none: "",
|
||||
daily: "FREQ=DAILY",
|
||||
weekly: "FREQ=WEEKLY",
|
||||
monthly: "FREQ=MONTHLY",
|
||||
yearly: "FREQ=YEARLY",
|
||||
};
|
||||
|
||||
const recurrencePreset = computed<string>({
|
||||
get() {
|
||||
const r = (recurrence.value || "").trim();
|
||||
if (!r) return "none";
|
||||
for (const [key, val] of Object.entries(RECURRENCE_PRESETS)) {
|
||||
if (val && val === r) return key;
|
||||
}
|
||||
return "custom";
|
||||
},
|
||||
set(key: string) {
|
||||
if (key === "custom") return; // no-op; can't pick custom from dropdown
|
||||
recurrence.value = RECURRENCE_PRESETS[key] ?? "";
|
||||
},
|
||||
});
|
||||
|
||||
const isCustomRecurrence = computed(() => recurrencePreset.value === "custom");
|
||||
|
||||
function dateFromIso(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso.slice(0, 10);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function timeFromIso(iso: string): string {
|
||||
if (!iso.includes("T")) return "09:00";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso.slice(11, 16);
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function toIso(date: string, time: string): string {
|
||||
if (!time) return `${date}T00:00:00`;
|
||||
// Include local timezone offset so the server stores the correct UTC time
|
||||
const local = new Date(`${date}T${time}:00`);
|
||||
const off = -local.getTimezoneOffset();
|
||||
const sign = off >= 0 ? "+" : "-";
|
||||
const h = String(Math.floor(Math.abs(off) / 60)).padStart(2, "0");
|
||||
const min = String(Math.abs(off) % 60).padStart(2, "0");
|
||||
return `${date}T${time}:00${sign}${h}:${min}`;
|
||||
}
|
||||
|
||||
// ── Time helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Round up to next 30-minute boundary */
|
||||
function nextRoundedTime(): string {
|
||||
const now = new Date();
|
||||
let h = now.getHours();
|
||||
let m = now.getMinutes();
|
||||
if (m <= 30) { m = 30; }
|
||||
else { m = 0; h = (h + 1) % 24; }
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Add hours to a time string (HH:MM), returns HH:MM */
|
||||
function addHours(time: string, hours: number): string {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
const totalMin = h * 60 + m + hours * 60;
|
||||
const nh = Math.floor(totalMin / 60) % 24;
|
||||
const nm = totalMin % 60;
|
||||
return `${String(nh).padStart(2, "0")}:${String(nm).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Duration in minutes between two time strings */
|
||||
function durationMin(start: string, end: string): number {
|
||||
const [sh, sm] = start.split(":").map(Number);
|
||||
const [eh, em] = end.split(":").map(Number);
|
||||
return (eh * 60 + em) - (sh * 60 + sm);
|
||||
}
|
||||
|
||||
/** True if a date+time is in the past */
|
||||
const isPastEvent = computed(() => {
|
||||
if (allDay.value || !startDate.value || !startTime.value) return false;
|
||||
const dt = new Date(`${startDate.value}T${startTime.value}:00`);
|
||||
return dt.getTime() < Date.now();
|
||||
});
|
||||
|
||||
// Track duration so end moves with start
|
||||
let _lastDurationMin = 60;
|
||||
|
||||
function resetForm() {
|
||||
if (props.event) {
|
||||
title.value = props.event.title;
|
||||
allDay.value = props.event.all_day;
|
||||
startDate.value = props.event.all_day ? props.event.start_dt.slice(0, 10) : dateFromIso(props.event.start_dt);
|
||||
startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt);
|
||||
endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : startDate.value;
|
||||
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : addHours(startTime.value || "09:00", 1);
|
||||
description.value = props.event.description || "";
|
||||
location.value = props.event.location || "";
|
||||
color.value = props.event.color || "";
|
||||
projectId.value = props.event.project_id;
|
||||
recurrence.value = props.event.recurrence || "";
|
||||
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
|
||||
if (_lastDurationMin <= 0) _lastDurationMin = 60;
|
||||
} else {
|
||||
title.value = "";
|
||||
allDay.value = false;
|
||||
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
|
||||
const roundedStart = nextRoundedTime();
|
||||
startDate.value = base;
|
||||
startTime.value = roundedStart;
|
||||
endDate.value = base;
|
||||
endTime.value = addHours(roundedStart, 1);
|
||||
description.value = "";
|
||||
location.value = "";
|
||||
color.value = "";
|
||||
projectId.value = null;
|
||||
recurrence.value = "";
|
||||
_lastDurationMin = 60;
|
||||
}
|
||||
deleteConfirm.value = false;
|
||||
}
|
||||
|
||||
// When start time changes, move end time to preserve duration
|
||||
watch(startTime, (newStart) => {
|
||||
if (allDay.value || !newStart) return;
|
||||
endTime.value = addHours(newStart, _lastDurationMin / 60);
|
||||
});
|
||||
|
||||
// When start date changes, move end date to match (preserve same-day or multi-day gap)
|
||||
watch(startDate, (newDate) => {
|
||||
if (!newDate) return;
|
||||
endDate.value = newDate;
|
||||
});
|
||||
|
||||
// When end time changes manually, update the tracked duration (but guard against end < start)
|
||||
watch(endTime, (newEnd) => {
|
||||
if (allDay.value || !newEnd || !startTime.value) return;
|
||||
const dur = durationMin(startTime.value, newEnd);
|
||||
if (dur <= 0) {
|
||||
// Snap back to start + 1 hour
|
||||
endTime.value = addHours(startTime.value, 1);
|
||||
_lastDurationMin = 60;
|
||||
} else {
|
||||
_lastDurationMin = dur;
|
||||
}
|
||||
});
|
||||
|
||||
// All-day toggle: clear/restore times
|
||||
watch(allDay, (isAllDay) => {
|
||||
if (isAllDay) {
|
||||
startTime.value = "";
|
||||
endTime.value = "";
|
||||
} else {
|
||||
const rounded = nextRoundedTime();
|
||||
startTime.value = rounded;
|
||||
endTime.value = addHours(rounded, 1);
|
||||
_lastDurationMin = 60;
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.event, resetForm, { immediate: true });
|
||||
watch(() => props.initialDate, resetForm);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
if (deleteConfirm.value) {
|
||||
// Esc cancels the delete-confirm rather than closing the modal —
|
||||
// gives the user a clear way out of the destructive prompt.
|
||||
deleteConfirm.value = false;
|
||||
return;
|
||||
}
|
||||
attemptClose();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener("keydown", handleKeydown));
|
||||
onUnmounted(() => document.removeEventListener("keydown", handleKeydown));
|
||||
|
||||
// ── Close / save flow ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// All exit paths (X button, Esc, backdrop click) funnel through `attemptClose`.
|
||||
// The Save button is gone — explicit-commit is replaced with auto-save-on-close.
|
||||
//
|
||||
// Validity-aware behavior:
|
||||
// - Form valid → save (PATCH for edit, POST for create), then close.
|
||||
// - Form invalid in EDIT mode → discard the in-memory change and close.
|
||||
// A toast tells the user what happened so they don't think their edit
|
||||
// silently landed.
|
||||
// - Form invalid in CREATE mode → close silently (nothing existed to begin
|
||||
// with; no need to call this out).
|
||||
|
||||
function isFormValid(): { valid: boolean; reason?: string } {
|
||||
if (!title.value.trim()) {
|
||||
return { valid: false, reason: "Title required" };
|
||||
}
|
||||
if (!startDate.value) {
|
||||
return { valid: false, reason: "Start date required" };
|
||||
}
|
||||
if (!allDay.value && !startTime.value) {
|
||||
return { valid: false, reason: "Start time required" };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
let _closing = false;
|
||||
|
||||
async function attemptClose() {
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
try {
|
||||
const validity = isFormValid();
|
||||
if (!validity.valid) {
|
||||
if (isEditMode.value) {
|
||||
toast.show(`${validity.reason} — change discarded`, "warning");
|
||||
}
|
||||
// Create mode + invalid: silent close. Nothing was committed.
|
||||
emit("close");
|
||||
return;
|
||||
}
|
||||
await save();
|
||||
emit("close");
|
||||
} finally {
|
||||
_closing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
|
||||
const end_dt = endDate.value
|
||||
? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value))
|
||||
: undefined;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEditMode.value && props.event) {
|
||||
const payload: EventUpdatePayload = {
|
||||
title: title.value.trim(),
|
||||
start_dt,
|
||||
end_dt,
|
||||
all_day: allDay.value,
|
||||
description: description.value,
|
||||
location: location.value,
|
||||
color: color.value,
|
||||
project_id: projectId.value ?? undefined,
|
||||
recurrence: recurrence.value || null,
|
||||
};
|
||||
const updated = await updateEvent(props.event.id, payload);
|
||||
emit("updated", updated);
|
||||
} else {
|
||||
const payload: EventCreatePayload = {
|
||||
title: title.value.trim(),
|
||||
start_dt,
|
||||
end_dt,
|
||||
all_day: allDay.value,
|
||||
description: description.value,
|
||||
location: location.value,
|
||||
color: color.value,
|
||||
project_id: projectId.value ?? undefined,
|
||||
recurrence: recurrence.value || undefined,
|
||||
};
|
||||
const created = await createEvent(payload);
|
||||
emit("created", created);
|
||||
}
|
||||
} catch {
|
||||
toast.show("Failed to save event", "error");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!props.event) return;
|
||||
deleting.value = true;
|
||||
try {
|
||||
await deleteEvent(props.event.id);
|
||||
toast.show("Event deleted", "success");
|
||||
emit("deleted", props.event.id);
|
||||
} catch {
|
||||
toast.show("Failed to delete event", "error");
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="modal-backdrop" @click.self="attemptClose">
|
||||
<div class="modal-panel" role="dialog" aria-modal="true">
|
||||
<!-- Header: trash + close (or inline delete-confirm) -->
|
||||
<div class="modal-header">
|
||||
<template v-if="!deleteConfirm">
|
||||
<h2 class="modal-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
class="header-btn header-btn-danger"
|
||||
@click="deleteConfirm = true"
|
||||
title="Delete event"
|
||||
aria-label="Delete event"
|
||||
><Trash2 :size="16" /></button>
|
||||
<button
|
||||
class="header-btn"
|
||||
@click="attemptClose"
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
><X :size="16" /></button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="delete-confirm-prompt">Delete this event?</span>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
:disabled="deleting"
|
||||
@click="doDelete"
|
||||
>{{ deleting ? "Deleting…" : "Yes, delete" }}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-confirm-cancel"
|
||||
@click="deleteConfirm = false"
|
||||
>No</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Body: form (scrolls if it gets long) -->
|
||||
<form class="modal-form" @submit.prevent="attemptClose">
|
||||
<!-- Title -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Title <span class="required">*</span></label>
|
||||
<input v-model="title" class="form-input" placeholder="Event title" autofocus />
|
||||
</div>
|
||||
|
||||
<!-- All-day toggle -->
|
||||
<div class="form-field form-field-row">
|
||||
<label class="form-label form-label-inline">All day</label>
|
||||
<button
|
||||
type="button"
|
||||
:class="['toggle-btn', { active: allDay }]"
|
||||
@click="allDay = !allDay"
|
||||
>{{ allDay ? "Yes" : "No" }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Start -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Start <span class="required">*</span></label>
|
||||
<div class="dt-row">
|
||||
<input v-model="startDate" type="date" class="form-input dt-date" required />
|
||||
<input v-if="!allDay" v-model="startTime" type="time" class="form-input dt-time" required />
|
||||
</div>
|
||||
<p v-if="isPastEvent" class="form-past-hint">This event is in the past</p>
|
||||
</div>
|
||||
|
||||
<!-- End -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">End</label>
|
||||
<div class="dt-row">
|
||||
<input v-model="endDate" type="date" class="form-input dt-date" :min="startDate" />
|
||||
<input v-if="!allDay" v-model="endTime" type="time" class="form-input dt-time" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recurrence -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Repeat</label>
|
||||
<select v-model="recurrencePreset" class="form-input">
|
||||
<option value="none">Does not repeat</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
<option v-if="isCustomRecurrence" value="custom" disabled>Custom</option>
|
||||
</select>
|
||||
<p v-if="isCustomRecurrence" class="recurrence-custom-hint">
|
||||
Custom rule: <code>{{ recurrence }}</code>
|
||||
<br />
|
||||
<span class="form-hint">Picking a preset will replace this rule.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Location <span class="form-hint">(optional)</span></label>
|
||||
<input v-model="location" class="form-input" placeholder="Location" />
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Description <span class="form-hint">(optional)</span></label>
|
||||
<textarea v-model="description" class="form-input form-textarea" placeholder="Description" rows="3" />
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div class="form-field form-field-row">
|
||||
<label class="form-label form-label-inline">Color</label>
|
||||
<div class="color-row">
|
||||
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
|
||||
<input v-model="color" class="form-input color-hex" placeholder="#5B4A8A" />
|
||||
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"><X :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Project <span class="form-hint">(optional)</span></label>
|
||||
<ProjectSelector v-model="projectId" />
|
||||
</div>
|
||||
|
||||
<!-- A hidden submit so Enter inside text inputs triggers attemptClose,
|
||||
matching the no-explicit-Save-button intent: Enter commits. -->
|
||||
<button type="submit" class="hidden-submit" :disabled="saving" tabindex="-1" aria-hidden="true" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 12px;
|
||||
width: min(480px, 100%);
|
||||
max-height: calc(100vh - 2.5rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem 0.85rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #2a2b30);
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted, #888);
|
||||
cursor: pointer;
|
||||
padding: 0.4rem;
|
||||
border-radius: 6px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.header-btn:hover {
|
||||
background: var(--color-hover, rgba(255,255,255,0.06));
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
/* Trash in header: subtle until hover, then Oxblood. Lower visual weight
|
||||
than Save would have been — destructive actions shouldn't loom. */
|
||||
.header-btn-danger:hover {
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Inline delete-confirm prompt replaces the title row */
|
||||
.delete-confirm-prompt {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Form scrolls inside the panel when content overflows */
|
||||
.modal-form {
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.form-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
|
||||
|
||||
.form-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.form-label-inline { flex-shrink: 0; margin: 0; }
|
||||
.form-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
|
||||
|
||||
.required { color: #f87171; }
|
||||
|
||||
.form-input {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text, #e8e9f0);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
font-size: 0.9rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.form-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
|
||||
.form-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
|
||||
|
||||
.dt-row { display: flex; gap: 0.5rem; }
|
||||
.dt-date { flex: 1; }
|
||||
.dt-time { width: 7.5rem; flex-shrink: 0; }
|
||||
.form-past-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.recurrence-custom-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.recurrence-custom-hint code {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text-muted, #888);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.color-row { display: flex; align-items: center; gap: 0.5rem; flex: 1; }
|
||||
.color-picker { width: 2.4rem; height: 2.2rem; border: none; padding: 0; border-radius: 4px; cursor: pointer; flex-shrink: 0; }
|
||||
.color-hex { flex: 1; }
|
||||
.btn-clear-color {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted, #888);
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Confirm-delete buttons (only shown during the inline confirm flow) */
|
||||
.btn-danger {
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
|
||||
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-confirm-cancel {
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-confirm-cancel:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* Hidden submit lets Enter-in-text-input trigger the same close-with-save
|
||||
path as X / Esc / backdrop. No visible Save button needed. */
|
||||
.hidden-submit {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0,0,0,0);
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface ForecastDay {
|
||||
day: string
|
||||
condition: string
|
||||
high: number
|
||||
low: number
|
||||
precip_probability: number | null
|
||||
precip_mm: number | null
|
||||
windspeed_max: number
|
||||
precip_summary?: string
|
||||
precip_peak_hour?: string
|
||||
}
|
||||
|
||||
interface WeatherData {
|
||||
location: string
|
||||
fetched_at: string
|
||||
current_temp: number
|
||||
condition: string
|
||||
today_high: number | null
|
||||
today_low: number | null
|
||||
yesterday_high: number | null
|
||||
yesterday_low: number | null
|
||||
wind_unit?: string
|
||||
precip_summary?: string | null
|
||||
forecast: ForecastDay[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
weather: WeatherData | null
|
||||
tempUnit?: string
|
||||
}>()
|
||||
|
||||
function weatherIcon(condition: string): string {
|
||||
const c = condition.toLowerCase()
|
||||
if (c.includes('thunderstorm')) return '⛈️'
|
||||
if (c.includes('hail')) return '🌨️'
|
||||
if (c.includes('snow showers')) return '🌨️'
|
||||
if (c.includes('snow')) return '❄️'
|
||||
if (c.includes('rain showers: violent')) return '⛈️'
|
||||
if (c.includes('rain showers')) return '🌦️'
|
||||
if (c.includes('drizzle') || c.includes('rain')) return '🌧️'
|
||||
if (c.includes('fog')) return '🌫️'
|
||||
if (c.includes('overcast')) return '☁️'
|
||||
if (c.includes('partly cloudy')) return '⛅'
|
||||
if (c.includes('mainly clear')) return '🌤️'
|
||||
if (c.includes('clear')) return '☀️'
|
||||
return '🌡️'
|
||||
}
|
||||
|
||||
const unit = computed(() => props.tempUnit ?? 'C')
|
||||
|
||||
const tempDelta = computed(() => {
|
||||
const w = props.weather
|
||||
if (!w || w.today_high == null || w.yesterday_high == null) return null
|
||||
const diff = w.today_high - w.yesterday_high
|
||||
if (Math.abs(diff) < 1) return 'Same as yesterday'
|
||||
const dir = diff > 0 ? 'warmer' : 'cooler'
|
||||
return `${Math.abs(diff)}° ${dir} than yesterday`
|
||||
})
|
||||
|
||||
const fetchedAtLabel = computed(() => {
|
||||
if (!props.weather?.fetched_at) return ''
|
||||
try {
|
||||
return new Date(props.weather.fetched_at).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
function hasPrecip(day: ForecastDay): boolean {
|
||||
return (day.precip_probability != null && day.precip_probability > 0) ||
|
||||
(day.precip_mm != null && day.precip_mm > 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="weather" class="weather-card">
|
||||
<div class="weather-header">
|
||||
<span class="weather-location">{{ weather.location }}</span>
|
||||
<span class="weather-fetched-at">as of {{ fetchedAtLabel }}</span>
|
||||
</div>
|
||||
<div class="weather-current">
|
||||
<span class="weather-icon">{{ weatherIcon(weather.condition) }}</span>
|
||||
<span class="weather-temp">{{ weather.current_temp }}°{{ unit }}</span>
|
||||
<span class="weather-condition">{{ weather.condition }}</span>
|
||||
</div>
|
||||
<div class="weather-today" v-if="weather.today_high != null">
|
||||
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
|
||||
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
|
||||
</div>
|
||||
<div v-if="weather.precip_summary" class="weather-precip-summary">
|
||||
💧 {{ weather.precip_summary }}
|
||||
</div>
|
||||
<table class="weather-forecast" v-if="weather.forecast.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th>Hi / Lo</th>
|
||||
<th>💧</th>
|
||||
<th>💨 {{ weather.wind_unit ?? 'km/h' }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="day in weather.forecast" :key="day.day">
|
||||
<td class="forecast-day-name">{{ day.day }}</td>
|
||||
<td class="forecast-icon">{{ weatherIcon(day.condition) }}</td>
|
||||
<td class="forecast-temps">{{ day.high }}° / {{ day.low }}°</td>
|
||||
<td class="forecast-precip" :class="{ 'forecast-precip--dry': !hasPrecip(day) }">
|
||||
<template v-if="day.precip_summary">
|
||||
<span class="precip-detail" :title="day.precip_summary">
|
||||
{{ day.precip_probability }}%
|
||||
<span v-if="day.precip_peak_hour" class="precip-peak">{{ day.precip_peak_hour }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="day.precip_probability != null && day.precip_probability > 0">{{ day.precip_probability }}%</template>
|
||||
<template v-else-if="day.precip_mm != null && day.precip_mm > 0">{{ day.precip_mm.toFixed(1) }}mm</template>
|
||||
<template v-else>—</template>
|
||||
</td>
|
||||
<td class="forecast-wind">{{ day.windspeed_max }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="weather-card weather-unavailable">
|
||||
Weather data unavailable — will retry at next slot.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.weather-card {
|
||||
background: color-mix(in srgb, var(--color-surface) 80%, transparent);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.weather-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-location {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.weather-fetched-at {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.weather-current {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-icon {
|
||||
font-size: clamp(1.5rem, 5cqi, 2.5rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.weather-temp {
|
||||
font-size: clamp(1.5rem, 5cqi, 2.5rem);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.weather-condition {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.weather-today {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.weather-delta {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.weather-precip-summary {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.83rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
border-left: 2px solid color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
}
|
||||
|
||||
.weather-forecast {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.weather-forecast thead th {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
padding: 0.5rem 0.4rem 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.weather-forecast thead th:first-child,
|
||||
.weather-forecast thead th:nth-child(2) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.weather-forecast tbody td {
|
||||
padding: 0.3rem 0.4rem;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.forecast-day-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.forecast-icon {
|
||||
font-size: clamp(0.9rem, 3cqi, 1.3rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.forecast-temps {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.forecast-precip {
|
||||
text-align: right;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.forecast-precip--dry {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.precip-detail {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3em;
|
||||
}
|
||||
|
||||
.precip-peak {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.forecast-wind {
|
||||
text-align: right;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.weather-unavailable {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -108,6 +108,11 @@ const router = createRouter({
|
||||
name: "shared-with-me",
|
||||
component: () => import("@/views/SharedWithMeView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/calendar",
|
||||
name: "calendar",
|
||||
component: () => import("@/views/CalendarView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
name: "settings",
|
||||
|
||||
@@ -31,6 +31,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
project_id?: number | null;
|
||||
milestone_id?: number | null;
|
||||
note_type?: string;
|
||||
metadata?: Record<string, string> | null;
|
||||
}): Promise<Note> {
|
||||
try {
|
||||
return await apiPost<Note>("/api/notes", data);
|
||||
@@ -42,7 +43,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
|
||||
async function updateNote(
|
||||
id: number,
|
||||
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type">>
|
||||
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type" | "metadata">>
|
||||
): Promise<Note> {
|
||||
try {
|
||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { System } from "@/api/systems";
|
||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||
export type TaskKind = "work" | "plan" | "issue";
|
||||
export type NoteType = "note" | "process";
|
||||
export type NoteType = "note" | "person" | "place" | "list" | "process";
|
||||
|
||||
export interface Note {
|
||||
id: number;
|
||||
@@ -28,6 +28,7 @@ export interface Note {
|
||||
task_kind?: TaskKind;
|
||||
systems?: System[];
|
||||
arose_from_id?: number | null;
|
||||
metadata: Record<string, string>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,763 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import FullCalendar from "@fullcalendar/vue3";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import type { CalendarOptions, EventClickArg, EventDropArg } from "@fullcalendar/core";
|
||||
import type { DateClickArg, EventResizeDoneArg } from "@fullcalendar/interaction";
|
||||
import { listEvents, updateEvent, type EventEntry } from "@/api/client";
|
||||
import EventSlideOver from "@/components/EventSlideOver.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { fmtTime, fmtDateTime, fmtDayLabel } from "@/utils/dateFormat";
|
||||
import { MapPin } from "lucide-vue-next";
|
||||
|
||||
const toast = useToastStore();
|
||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
||||
|
||||
// Slide-over state
|
||||
const slideOverEvent = ref<EventEntry | null>(null);
|
||||
const slideOverOpen = ref(false);
|
||||
const slideOverDate = ref<string>("");
|
||||
|
||||
function openCreate(date: string) {
|
||||
slideOverEvent.value = null;
|
||||
slideOverDate.value = date;
|
||||
slideOverOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(event: EventEntry) {
|
||||
slideOverEvent.value = event;
|
||||
slideOverDate.value = "";
|
||||
slideOverOpen.value = true;
|
||||
}
|
||||
|
||||
function closeSlideOver() {
|
||||
slideOverOpen.value = false;
|
||||
}
|
||||
|
||||
// Event entry cache keyed by id
|
||||
const eventCache = new Map<number, EventEntry>();
|
||||
|
||||
function toFcEvent(e: EventEntry) {
|
||||
// For all-day events pass date-only strings so FullCalendar never shifts
|
||||
// the date through timezone conversion (UTC midnight → previous day in UTC-X).
|
||||
return {
|
||||
id: String(e.id),
|
||||
title: e.title,
|
||||
start: e.all_day ? e.start_dt.slice(0, 10) : e.start_dt,
|
||||
end: e.all_day ? (e.end_dt?.slice(0, 10) ?? undefined) : (e.end_dt ?? undefined),
|
||||
allDay: e.all_day,
|
||||
backgroundColor: e.color || undefined,
|
||||
borderColor: e.color || undefined,
|
||||
extendedProps: { entryId: e.id },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Upcoming events list ───────────────────────────────────────────────────
|
||||
const upcomingEvents = ref<EventEntry[]>([]);
|
||||
|
||||
async function loadUpcoming() {
|
||||
const now = new Date();
|
||||
const end = new Date(now.getTime() + 28 * 86_400_000); // 4 weeks
|
||||
try {
|
||||
const entries = await listEvents(now.toISOString(), end.toISOString());
|
||||
upcomingEvents.value = entries.sort(
|
||||
(a, b) => new Date(a.start_dt).getTime() - new Date(b.start_dt).getTime()
|
||||
);
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
onMounted(loadUpcoming);
|
||||
|
||||
// ── Month/year picker ──────────────────────────────────────────────────────
|
||||
const currentViewYear = ref(new Date().getFullYear());
|
||||
const currentViewMonth = ref(new Date().getMonth());
|
||||
const pickerOpen = ref(false);
|
||||
const pickerYear = ref(new Date().getFullYear());
|
||||
const pickerStyle = ref<Record<string, string>>({});
|
||||
const pickerEl = ref<HTMLElement | null>(null);
|
||||
|
||||
const MONTH_NAMES = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const;
|
||||
|
||||
function handleDatesSet(arg: { view: { currentStart: Date } }) {
|
||||
const d = arg.view.currentStart;
|
||||
currentViewYear.value = d.getFullYear();
|
||||
currentViewMonth.value = d.getMonth();
|
||||
}
|
||||
|
||||
function jumpTo(year: number, month: number) {
|
||||
calendarRef.value?.getApi().gotoDate(new Date(year, month, 1));
|
||||
pickerOpen.value = false;
|
||||
}
|
||||
|
||||
// ── Event popover ──────────────────────────────────────────────────────────
|
||||
const popover = ref<EventEntry | null>(null);
|
||||
const popoverStyle = ref<Record<string, string>>({});
|
||||
const popoverEl = ref<HTMLElement | null>(null);
|
||||
|
||||
function showPopover(entry: EventEntry, clickEvent: MouseEvent) {
|
||||
popover.value = entry;
|
||||
nextTickPositionPopover(clickEvent);
|
||||
}
|
||||
|
||||
function nextTickPositionPopover(clickEvent: MouseEvent) {
|
||||
// Position after DOM update
|
||||
requestAnimationFrame(() => {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const pw = 280;
|
||||
const ph = 220; // approximate
|
||||
let left = clickEvent.clientX + 8;
|
||||
let top = clickEvent.clientY + 8;
|
||||
if (left + pw > vw - 16) left = clickEvent.clientX - pw - 8;
|
||||
if (top + ph > vh - 16) top = clickEvent.clientY - ph - 8;
|
||||
popoverStyle.value = {
|
||||
position: "fixed",
|
||||
left: `${Math.max(8, left)}px`,
|
||||
top: `${Math.max(8, top)}px`,
|
||||
zIndex: "9999",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function closePopover() {
|
||||
popover.value = null;
|
||||
}
|
||||
|
||||
function onPopoverEdit() {
|
||||
if (popover.value) {
|
||||
openEdit(popover.value);
|
||||
closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
// Close popover / open picker on outside or title click
|
||||
function onDocClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
// Title click → toggle month/year picker
|
||||
const titleEl = target.closest(".fc-toolbar-title");
|
||||
if (titleEl) {
|
||||
if (!pickerOpen.value) {
|
||||
pickerYear.value = currentViewYear.value;
|
||||
const rect = titleEl.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(rect.left + rect.width / 2 - 140, window.innerWidth - 296));
|
||||
pickerStyle.value = {
|
||||
position: "fixed",
|
||||
top: `${rect.bottom + 6}px`,
|
||||
left: `${left}px`,
|
||||
zIndex: "9999",
|
||||
};
|
||||
}
|
||||
pickerOpen.value = !pickerOpen.value;
|
||||
return;
|
||||
}
|
||||
// Close picker on outside click
|
||||
if (pickerOpen.value && pickerEl.value && !pickerEl.value.contains(target)) {
|
||||
pickerOpen.value = false;
|
||||
}
|
||||
// Close event popover on outside click
|
||||
if (popover.value && popoverEl.value && !popoverEl.value.contains(target)) {
|
||||
closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
function onCalendarChanged() {
|
||||
calendarRef.value?.getApi().refetchEvents();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
document.addEventListener("scribe:calendar-changed", onCalendarChanged);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("mousedown", onDocClick);
|
||||
document.removeEventListener("scribe:calendar-changed", onCalendarChanged);
|
||||
});
|
||||
|
||||
// ── Calendar callbacks ─────────────────────────────────────────────────────
|
||||
async function loadEvents(
|
||||
fetchInfo: { startStr: string; endStr: string },
|
||||
successCallback: (events: object[]) => void,
|
||||
failureCallback: (error: Error) => void,
|
||||
) {
|
||||
try {
|
||||
const entries = await listEvents(fetchInfo.startStr, fetchInfo.endStr);
|
||||
eventCache.clear();
|
||||
for (const e of entries) eventCache.set(e.id, e);
|
||||
successCallback(entries.map(toFcEvent));
|
||||
loadUpcoming();
|
||||
} catch (err) {
|
||||
failureCallback(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDateClick(arg: DateClickArg) {
|
||||
closePopover();
|
||||
openCreate(arg.dateStr);
|
||||
}
|
||||
|
||||
function handleEventClick(arg: EventClickArg) {
|
||||
const id = arg.event.extendedProps.entryId as number;
|
||||
const entry = eventCache.get(id);
|
||||
if (entry) showPopover(entry, arg.jsEvent as MouseEvent);
|
||||
}
|
||||
|
||||
async function handleEventDrop(arg: EventDropArg) {
|
||||
const id = arg.event.extendedProps.entryId as number;
|
||||
const start_dt = arg.event.startStr;
|
||||
const end_dt = arg.event.endStr || undefined;
|
||||
try {
|
||||
const updated = await updateEvent(id, { start_dt, end_dt, all_day: arg.event.allDay });
|
||||
eventCache.set(id, updated);
|
||||
loadUpcoming();
|
||||
} catch {
|
||||
arg.revert();
|
||||
toast.show("Failed to move event", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEventResize(arg: EventResizeDoneArg) {
|
||||
const id = arg.event.extendedProps.entryId as number;
|
||||
const start_dt = arg.event.startStr;
|
||||
const end_dt = arg.event.endStr || undefined;
|
||||
try {
|
||||
const updated = await updateEvent(id, { start_dt, end_dt });
|
||||
eventCache.set(id, updated);
|
||||
loadUpcoming();
|
||||
} catch {
|
||||
arg.revert();
|
||||
toast.show("Failed to resize event", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function onCreated(entry: EventEntry) {
|
||||
eventCache.set(entry.id, entry);
|
||||
calendarRef.value?.getApi().addEvent(toFcEvent(entry));
|
||||
closeSlideOver();
|
||||
loadUpcoming();
|
||||
}
|
||||
|
||||
function onUpdated(entry: EventEntry) {
|
||||
eventCache.set(entry.id, entry);
|
||||
const api = calendarRef.value?.getApi();
|
||||
if (api) {
|
||||
const existing = api.getEventById(String(entry.id));
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
api.addEvent(toFcEvent(entry));
|
||||
}
|
||||
}
|
||||
closeSlideOver();
|
||||
loadUpcoming();
|
||||
}
|
||||
|
||||
function onDeleted(id: number) {
|
||||
eventCache.delete(id);
|
||||
const api = calendarRef.value?.getApi();
|
||||
if (api) api.getEventById(String(id))?.remove();
|
||||
closeSlideOver();
|
||||
upcomingEvents.value = upcomingEvents.value.filter((e) => e.id !== id);
|
||||
}
|
||||
|
||||
const calendarOptions: CalendarOptions = {
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||
initialView: "dayGridMonth",
|
||||
timeZone: "local",
|
||||
editable: true,
|
||||
selectable: false,
|
||||
headerToolbar: {
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
||||
},
|
||||
events: loadEvents,
|
||||
datesSet: handleDatesSet,
|
||||
dateClick: handleDateClick,
|
||||
eventClick: handleEventClick,
|
||||
eventDrop: handleEventDrop,
|
||||
eventResize: handleEventResize,
|
||||
height: "auto",
|
||||
};
|
||||
|
||||
// Group upcoming events by day label
|
||||
const upcomingGrouped = computed(() => {
|
||||
const groups: { label: string; date: string; events: EventEntry[] }[] = [];
|
||||
for (const e of upcomingEvents.value) {
|
||||
const label = fmtDayLabel(e.start_dt);
|
||||
const existing = groups.find((g) => g.label === label);
|
||||
if (existing) {
|
||||
existing.events.push(e);
|
||||
} else {
|
||||
groups.push({ label, date: e.start_dt, events: [e] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar-view">
|
||||
<div class="cal-header">
|
||||
<h1 class="cal-title">Calendar</h1>
|
||||
<button class="btn-new-event" @click="openCreate(new Date().toISOString().slice(0, 10))">
|
||||
+ New Event
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fc-wrapper">
|
||||
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
||||
</div>
|
||||
|
||||
<!-- ── Upcoming events strip ───────────────────────────────────────── -->
|
||||
<div v-if="upcomingEvents.length" class="upcoming-section">
|
||||
<h2 class="upcoming-title">Upcoming</h2>
|
||||
<div class="upcoming-groups">
|
||||
<div v-for="group in upcomingGrouped" :key="group.label" class="upcoming-group">
|
||||
<div class="upcoming-day-label">{{ group.label }}</div>
|
||||
<div class="upcoming-cards">
|
||||
<div
|
||||
v-for="ev in group.events"
|
||||
:key="ev.id"
|
||||
class="upcoming-card"
|
||||
:style="ev.color ? { '--ev-color': ev.color } : {}"
|
||||
@click="openEdit(ev)"
|
||||
>
|
||||
<div class="upcoming-card-accent"></div>
|
||||
<div class="upcoming-card-body">
|
||||
<div class="upcoming-card-title">{{ ev.title }}</div>
|
||||
<div class="upcoming-card-time">
|
||||
<template v-if="ev.all_day">All day</template>
|
||||
<template v-else>
|
||||
{{ fmtTime(ev.start_dt) }}
|
||||
<span v-if="ev.end_dt"> – {{ fmtTime(ev.end_dt) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="ev.location" class="upcoming-card-meta">
|
||||
<MapPin :size="16" style="flex-shrink:0" />
|
||||
{{ ev.location }}
|
||||
</div>
|
||||
<div v-if="ev.description" class="upcoming-card-desc">{{ ev.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Event popover ───────────────────────────────────────────────── -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="popover"
|
||||
ref="popoverEl"
|
||||
class="event-popover"
|
||||
:style="popoverStyle"
|
||||
>
|
||||
<div class="popover-accent" :style="popover.color ? { background: popover.color } : {}"></div>
|
||||
<div class="popover-content">
|
||||
<div class="popover-title">{{ popover.title }}</div>
|
||||
<div class="popover-time">
|
||||
<template v-if="popover.all_day">
|
||||
{{ fmtDateTime(popover.start_dt, true) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ fmtDateTime(popover.start_dt, false) }}
|
||||
<span v-if="popover.end_dt"> – {{ fmtTime(popover.end_dt) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="popover.location" class="popover-meta">
|
||||
<MapPin :size="16" style="flex-shrink:0;margin-top:1px" />
|
||||
{{ popover.location }}
|
||||
</div>
|
||||
<div v-if="popover.description" class="popover-desc">{{ popover.description }}</div>
|
||||
<div class="popover-actions">
|
||||
<button class="popover-btn popover-btn--edit" @click="onPopoverEdit">Edit</button>
|
||||
<button class="popover-btn popover-btn--close" @click="closePopover">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Month/year picker ──────────────────────────────────────────── -->
|
||||
<Teleport to="body">
|
||||
<div v-if="pickerOpen" ref="pickerEl" class="month-picker" :style="pickerStyle">
|
||||
<div class="picker-year-row">
|
||||
<button class="picker-year-btn" @click="pickerYear--" aria-label="Previous year">‹</button>
|
||||
<span class="picker-year-label">{{ pickerYear }}</span>
|
||||
<button class="picker-year-btn" @click="pickerYear++" aria-label="Next year">›</button>
|
||||
</div>
|
||||
<div class="picker-months">
|
||||
<button
|
||||
v-for="(name, i) in MONTH_NAMES"
|
||||
:key="i"
|
||||
class="picker-month"
|
||||
:class="{ active: pickerYear === currentViewYear && i === currentViewMonth }"
|
||||
@click="jumpTo(pickerYear, i)"
|
||||
>{{ name }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<EventSlideOver
|
||||
v-if="slideOverOpen"
|
||||
:event="slideOverEvent"
|
||||
:initial-date="slideOverDate"
|
||||
@close="closeSlideOver"
|
||||
@created="onCreated"
|
||||
@updated="onUpdated"
|
||||
@deleted="onDeleted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-view {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.5rem 3rem;
|
||||
}
|
||||
|
||||
.cal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.cal-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
/* New event: Moss action-primary — workflow action, not a brand moment */
|
||||
.btn-new-event {
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-new-event:hover { background: var(--color-action-primary-hover); }
|
||||
|
||||
.fc-wrapper {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: var(--radius-lg, 18px);
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* FullCalendar dark theme overrides */
|
||||
:deep(.fc) {
|
||||
color: var(--color-text, #e8e9f0);
|
||||
font-family: inherit;
|
||||
}
|
||||
:deep(.fc-toolbar-title) {
|
||||
/* FullCalendar renders this as <h2>, which would otherwise pick up the
|
||||
theme.css h1/h2 → Fraunces rule. At 1.1rem it's below the doc's
|
||||
"Fraunces only at ≥18px" threshold, so explicit Inter. */
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
transition: background 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
:deep(.fc-toolbar-title:hover) {
|
||||
background: rgba(255,255,255,0.07);
|
||||
}
|
||||
:deep(.fc-button) {
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
:deep(.fc-button:hover),
|
||||
:deep(.fc-button-active) {
|
||||
background: var(--color-primary) !important;
|
||||
border-color: var(--color-primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
:deep(.fc-button:focus) { box-shadow: none !important; }
|
||||
:deep(.fc-daygrid-day-number),
|
||||
:deep(.fc-col-header-cell-cushion) {
|
||||
color: var(--color-text-muted, #888);
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
:deep(.fc-daygrid-day.fc-day-today) {
|
||||
background: var(--color-primary-tint);
|
||||
}
|
||||
:deep(.fc-event) {
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
padding: 1px 4px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
:deep(.fc-event-main) { color: #fff; }
|
||||
:deep(.fc-event:not([style*="background"])) {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
:deep(.fc-daygrid-day-frame) { min-height: 5rem; }
|
||||
:deep(.fc-scrollgrid) { border-color: var(--color-border, #2a2b30); }
|
||||
:deep(.fc-scrollgrid td),
|
||||
:deep(.fc-scrollgrid th) { border-color: var(--color-border, #2a2b30); }
|
||||
:deep(.fc-daygrid-day) { cursor: pointer; }
|
||||
:deep(.fc-daygrid-day:hover) { background: rgba(255,255,255,0.03); }
|
||||
|
||||
/* ── Month/year picker ──────────────────────────────────────────────────── */
|
||||
.month-picker {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
width: 280px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.picker-year-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.picker-year-label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.picker-year-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-muted, #888);
|
||||
cursor: pointer;
|
||||
padding: 2px 10px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.picker-year-btn:hover {
|
||||
color: var(--color-text, #e8e9f0);
|
||||
border-color: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
.picker-months {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.picker-month {
|
||||
padding: 7px 4px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
cursor: pointer;
|
||||
font-size: 0.84rem;
|
||||
text-align: center;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.picker-month:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
.picker-month.active {
|
||||
background: rgba(91, 74, 138,0.22);
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Upcoming strip ─────────────────────────────────────────────────────── */
|
||||
.upcoming-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.upcoming-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 0.78rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.upcoming-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.upcoming-day-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.upcoming-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.upcoming-card {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.upcoming-card:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.upcoming-card-accent {
|
||||
width: 4px;
|
||||
flex-shrink: 0;
|
||||
background: var(--ev-color, #5B4A8A);
|
||||
}
|
||||
|
||||
.upcoming-card-body {
|
||||
padding: 0.6rem 0.85rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.upcoming-card-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.upcoming-card-time {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.upcoming-card-meta {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.upcoming-card-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
margin-top: 0.3rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Event popover ──────────────────────────────────────────────────────── */
|
||||
.event-popover {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
|
||||
width: 280px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.popover-accent {
|
||||
height: 4px;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
padding: 0.85rem 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.popover-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.popover-time {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.popover-meta {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.popover-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
line-height: 1.45;
|
||||
margin-bottom: 0.6rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.popover-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border, #2a2b30);
|
||||
}
|
||||
|
||||
.popover-btn {
|
||||
flex: 1;
|
||||
padding: 0.35rem 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.popover-btn:hover { opacity: 0.85; }
|
||||
.popover-btn--edit {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.popover-btn--close {
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
color: var(--color-text-muted, #888);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
}
|
||||
</style>
|
||||
@@ -11,11 +11,13 @@ interface ActiveProject {
|
||||
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
|
||||
}
|
||||
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
|
||||
interface UpcomingEvent { id: number; title: string; start_dt: string | null; all_day: boolean }
|
||||
interface WeekStats { completed_this_week: number; open_total: number; in_progress: number; active_plans: number }
|
||||
interface IssueRow { id: number; title: string; status: string; priority: string; project_id: number | null; project_title: string | null }
|
||||
interface DashboardData {
|
||||
active_projects: ActiveProject[];
|
||||
recently_completed: DoneItem[];
|
||||
upcoming_events: UpcomingEvent[];
|
||||
open_issues: IssueRow[];
|
||||
week_stats: WeekStats;
|
||||
}
|
||||
@@ -29,11 +31,19 @@ onMounted(async () => {
|
||||
try {
|
||||
data.value = await apiGet<DashboardData>("/api/dashboard");
|
||||
} catch {
|
||||
data.value = { active_projects: [], recently_completed: [], open_issues: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
|
||||
data.value = { active_projects: [], recently_completed: [], upcoming_events: [], open_issues: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function fmtEvent(e: UpcomingEvent): string {
|
||||
if (!e.start_dt) return "";
|
||||
const d = new Date(e.start_dt);
|
||||
const day = d.toLocaleDateString(undefined, { weekday: "short" });
|
||||
if (e.all_day) return day;
|
||||
return `${day} ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -140,6 +150,17 @@ onMounted(async () => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="dash-label">Upcoming · 7 days</div>
|
||||
<div class="rail-card">
|
||||
<template v-if="data.upcoming_events.length">
|
||||
<div v-for="e in data.upcoming_events" :key="e.id" class="evt-row">
|
||||
<span class="evt-when">{{ fmtEvent(e) }}</span>
|
||||
<span class="evt-title">{{ e.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="rail-empty">Nothing scheduled.</p>
|
||||
</div>
|
||||
|
||||
<div class="dash-label">This week</div>
|
||||
<div class="rail-card stats">
|
||||
<span>✓ {{ data.week_stats.completed_this_week }} done</span>
|
||||
@@ -220,6 +241,11 @@ onMounted(async () => {
|
||||
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
|
||||
|
||||
.rail-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
|
||||
.evt-row { display: flex; gap: 0.6rem; padding: 0.3rem 0; border-bottom: 1px solid var(--color-border); font-size: 0.85rem; }
|
||||
.evt-row:last-child { border-bottom: none; }
|
||||
.evt-when { color: var(--color-muted); white-space: nowrap; }
|
||||
.evt-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rail-empty { margin: 0; color: var(--color-muted); font-size: 0.85rem; }
|
||||
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
|
||||
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
|
||||
.proj-stats { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { apiGet, apiPatch, listEvents } from "@/api/client";
|
||||
import { fmtCompact } from "@/utils/dateFormat";
|
||||
import GraphView from "@/views/GraphView.vue";
|
||||
import {
|
||||
FileText,
|
||||
CheckSquare,
|
||||
User,
|
||||
MapPin,
|
||||
List,
|
||||
Workflow,
|
||||
Search,
|
||||
Share2,
|
||||
@@ -20,13 +24,28 @@ const router = useRouter();
|
||||
|
||||
interface KnowledgeItem {
|
||||
id: number;
|
||||
note_type: "note" | "task" | "process";
|
||||
note_type: "note" | "person" | "place" | "list" | "task" | "process";
|
||||
title: string;
|
||||
snippet: string;
|
||||
tags: string[];
|
||||
project_id: number | null;
|
||||
metadata: Record<string, string>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// type-specific
|
||||
relationship?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
birthday?: string;
|
||||
organization?: string;
|
||||
address?: string;
|
||||
hours?: string;
|
||||
website?: string;
|
||||
category?: string;
|
||||
item_count?: number;
|
||||
checked_count?: number;
|
||||
list_items?: { text: string; checked: boolean }[];
|
||||
body?: string;
|
||||
// Task-specific
|
||||
status?: string;
|
||||
priority?: string;
|
||||
@@ -34,9 +53,16 @@ interface KnowledgeItem {
|
||||
task_kind?: "work" | "plan";
|
||||
}
|
||||
|
||||
interface UpcomingEvent {
|
||||
id: number;
|
||||
title: string;
|
||||
start_dt: string;
|
||||
all_day: boolean;
|
||||
}
|
||||
|
||||
// ─── Filter state ─────────────────────────────────────────────────────────────
|
||||
|
||||
const activeType = ref<"" | "note" | "task" | "plan" | "process">("");
|
||||
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task" | "plan" | "process">("");
|
||||
const activeTag = ref("");
|
||||
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||
const searchQuery = ref("");
|
||||
@@ -44,8 +70,8 @@ let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// ─── Type counts ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface KnowledgeCounts { note: number; task: number; plan: number; process: number; total: number }
|
||||
const typeCounts = ref<KnowledgeCounts>({ note: 0, task: 0, plan: 0, process: 0, total: 0 });
|
||||
interface KnowledgeCounts { note: number; person: number; place: number; list: number; task: number; plan: number; process: number; total: number }
|
||||
const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, task: 0, plan: 0, process: 0, total: 0 });
|
||||
|
||||
async function fetchCounts() {
|
||||
try {
|
||||
@@ -186,17 +212,26 @@ watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
|
||||
|
||||
// ─── Today bar ────────────────────────────────────────────────────────────────
|
||||
|
||||
const upcomingEvents = ref<UpcomingEvent[]>([]);
|
||||
const overdueCount = ref(0);
|
||||
|
||||
async function fetchTodayBar() {
|
||||
try {
|
||||
const taskData = await apiGet<{ total: number }>(
|
||||
const now = new Date();
|
||||
const end = new Date(now.getTime() + 7 * 86_400_000);
|
||||
const [events, taskData] = await Promise.all([
|
||||
listEvents(now.toISOString(), end.toISOString()).catch(() => [] as UpcomingEvent[]),
|
||||
apiGet<{ total: number }>(
|
||||
`/api/tasks?status=todo&status=in_progress&overdue=true&limit=1`
|
||||
).catch(() => ({ total: 0 }));
|
||||
).catch(() => ({ total: 0 })),
|
||||
]);
|
||||
upcomingEvents.value = events.slice(0, 3);
|
||||
overdueCount.value = taskData.total ?? 0;
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
const formatEventDate = fmtCompact
|
||||
|
||||
// ─── Graph panel ──────────────────────────────────────────────────────────────
|
||||
|
||||
const _GRAPH_OPEN_KEY = 'fa_knowledge_graph_open'
|
||||
@@ -215,6 +250,40 @@ function toggleGraphExpand() {
|
||||
localStorage.setItem(_GRAPH_EXP_KEY, String(graphExpanded.value))
|
||||
}
|
||||
|
||||
// ─── List item toggle ─────────────────────────────────────────────────────────
|
||||
|
||||
async function toggleListItem(item: KnowledgeItem, index: number) {
|
||||
if (!item.list_items || item.body === undefined) return;
|
||||
|
||||
// Optimistic update
|
||||
const newChecked = !item.list_items[index].checked;
|
||||
item.list_items[index].checked = newChecked;
|
||||
item.checked_count = item.list_items.filter(i => i.checked).length;
|
||||
|
||||
// Rebuild the body by replacing the targeted checkbox line
|
||||
let listIdx = 0;
|
||||
const newBody = (item.body).split('\n').map(line => {
|
||||
const stripped = line.trimStart();
|
||||
if (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] ')) {
|
||||
if (listIdx === index) {
|
||||
const indent = line.length - stripped.length;
|
||||
const newLine = (' '.repeat(indent)) + (newChecked ? '- [x] ' : '- [ ] ') + item.list_items![index].text;
|
||||
listIdx++;
|
||||
return newLine;
|
||||
}
|
||||
listIdx++;
|
||||
}
|
||||
return line;
|
||||
}).join('\n');
|
||||
|
||||
item.body = newBody;
|
||||
try {
|
||||
await apiPatch(`/api/notes/${item.id}`, { body: newBody });
|
||||
} catch {
|
||||
reset(); // revert on error
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Navigation helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function isOverdue(item: KnowledgeItem): boolean {
|
||||
@@ -283,9 +352,22 @@ onUnmounted(() => {
|
||||
<div class="knowledge-root" :class="{ 'graph-open': graphOpen, 'graph-expanded': graphExpanded && graphOpen }">
|
||||
|
||||
<!-- Today bar -->
|
||||
<div v-if="overdueCount > 0" class="today-bar">
|
||||
<div class="today-bar">
|
||||
<div class="today-events">
|
||||
<span v-if="upcomingEvents.length === 0" class="today-empty">No upcoming events</span>
|
||||
<router-link
|
||||
v-for="ev in upcomingEvents"
|
||||
:key="ev.id"
|
||||
:to="`/calendar`"
|
||||
class="today-event-chip"
|
||||
>
|
||||
<span class="chip-dot"></span>
|
||||
{{ ev.title }}
|
||||
<span class="chip-date">{{ formatEventDate(ev.start_dt, ev.all_day) }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="today-actions">
|
||||
<router-link to="/tasks" class="overdue-badge">
|
||||
<router-link v-if="overdueCount > 0" to="/tasks" class="overdue-badge">
|
||||
{{ overdueCount }} overdue
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -310,6 +392,18 @@ onUnmounted(() => {
|
||||
<CheckSquare :size="16" />
|
||||
Task
|
||||
</button>
|
||||
<button @click="createNew('person')">
|
||||
<User :size="16" />
|
||||
Person
|
||||
</button>
|
||||
<button @click="createNew('place')">
|
||||
<MapPin :size="16" />
|
||||
Place
|
||||
</button>
|
||||
<button @click="createNew('list')">
|
||||
<List :size="16" />
|
||||
List
|
||||
</button>
|
||||
<button @click="createNew('process')">
|
||||
<Workflow :size="16" />
|
||||
Process
|
||||
@@ -328,11 +422,11 @@ onUnmounted(() => {
|
||||
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['process','Processes','process']] as [string,string,string][])"
|
||||
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['person','People','person'],['place','Places','place'],['list','Lists','list'],['process','Processes','process']] as [string,string,string][])"
|
||||
:key="val"
|
||||
class="filter-btn"
|
||||
:class="{ active: activeType === val }"
|
||||
@click="activeType = (val as '' | 'note' | 'task' | 'plan' | 'process')"
|
||||
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task' | 'plan' | 'process')"
|
||||
>
|
||||
<span class="filter-btn-label">{{ label }}</span>
|
||||
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
||||
@@ -402,15 +496,54 @@ onUnmounted(() => {
|
||||
<!-- Type badge -->
|
||||
<span class="type-badge" :class="item.task_kind === 'plan' ? 'badge--plan' : `badge--${item.note_type}`">
|
||||
<span v-if="item.note_type === 'note'">Note</span>
|
||||
<span v-else-if="item.note_type === 'person'">Person</span>
|
||||
<span v-else-if="item.note_type === 'place'">Place</span>
|
||||
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
|
||||
<span v-else-if="item.note_type === 'process'">Process</span>
|
||||
<span v-else>List</span>
|
||||
</span>
|
||||
|
||||
<div class="k-card-body">
|
||||
<div class="k-card-title">{{ item.title }}</div>
|
||||
|
||||
<!-- Person specifics -->
|
||||
<div v-if="item.note_type === 'person'" class="k-card-meta">
|
||||
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
|
||||
<span v-if="item.organization" class="meta-muted">{{ item.organization }}</span>
|
||||
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Place specifics -->
|
||||
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
|
||||
<span v-if="item.category" class="meta-chip">{{ item.category }}</span>
|
||||
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
|
||||
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
|
||||
</div>
|
||||
|
||||
<!-- List specifics -->
|
||||
<div v-else-if="item.note_type === 'list'" class="k-card-list" @click.stop>
|
||||
<label
|
||||
v-for="(li, idx) in (item.list_items ?? []).slice(0, 6)"
|
||||
:key="idx"
|
||||
class="list-item-row"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="li.checked"
|
||||
@change="toggleListItem(item, idx)"
|
||||
/>
|
||||
<span :class="{ 'list-item-done': li.checked }">{{ li.text }}</span>
|
||||
</label>
|
||||
<div v-if="(item.list_items?.length ?? 0) > 6" class="list-item-more">
|
||||
+{{ (item.list_items?.length ?? 0) - 6 }} more
|
||||
</div>
|
||||
<span class="list-progress" style="margin-top: 6px;">
|
||||
<span class="list-progress-bar" :style="{ width: item.item_count ? `${Math.round(((item.checked_count ?? 0) / item.item_count) * 100)}%` : '0%' }"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Task specifics -->
|
||||
<div v-if="item.note_type === 'task'" class="k-card-task">
|
||||
<div v-else-if="item.note_type === 'task'" class="k-card-task">
|
||||
<div class="task-badges">
|
||||
<span class="status-badge" :class="`status--${item.status}`">
|
||||
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
|
||||
@@ -498,6 +631,35 @@ onUnmounted(() => {
|
||||
font-size: 0.82rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.today-events {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.today-empty {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.today-event-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(91, 74, 138, 0.1);
|
||||
border: 1px solid rgba(91, 74, 138, 0.2);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.today-event-chip:hover { background: rgba(91, 74, 138, 0.18); }
|
||||
.chip-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #5B4A8A;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip-date { color: var(--color-muted); font-size: 0.78rem; }
|
||||
.today-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.overdue-badge {
|
||||
padding: 2px 9px;
|
||||
@@ -767,10 +929,14 @@ onUnmounted(() => {
|
||||
/* Type-specific card DNA */
|
||||
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
|
||||
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
|
||||
.k-card--person { border-color: rgba(16, 185, 129, 0.18); }
|
||||
.k-card--place { border-color: rgba(245, 158, 11, 0.18); }
|
||||
.k-card--list { border-color: rgba(56, 189, 248, 0.18); }
|
||||
|
||||
/* Top gradient bars */
|
||||
.k-card--note::before,
|
||||
.k-card--task::before {
|
||||
.k-card--task::before,
|
||||
.k-card--list::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -786,6 +952,25 @@ onUnmounted(() => {
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, #d4a017, #fbbf24);
|
||||
}
|
||||
.k-card--list::before {
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, #38bdf8, #7dd3fc);
|
||||
}
|
||||
|
||||
/* Corner accents for entity types */
|
||||
.k-card--person::after,
|
||||
.k-card--place::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 0 14px 0 60px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.k-card--person::after { background: rgba(16, 185, 129, 0.06); }
|
||||
.k-card--place::after { background: rgba(245, 158, 11, 0.06); }
|
||||
|
||||
/* Type badge */
|
||||
.type-badge {
|
||||
@@ -800,6 +985,9 @@ onUnmounted(() => {
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.badge--note { background: rgba(91, 74, 138,0.15); color: #7A6DA8; }
|
||||
.badge--person { background: rgba(16,185,129,0.15); color: #34d399; }
|
||||
.badge--place { background: rgba(245,158,11,0.15); color: #fbbf24; }
|
||||
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
|
||||
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
|
||||
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
|
||||
|
||||
@@ -824,6 +1012,70 @@ onUnmounted(() => {
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
}
|
||||
.k-card-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.meta-chip {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
font-size: 0.75rem;
|
||||
width: fit-content;
|
||||
}
|
||||
.meta-muted { color: var(--color-muted); }
|
||||
|
||||
/* List card items */
|
||||
.k-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.list-item-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.list-item-row input[type="checkbox"] {
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.list-item-done {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.list-item-more {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* List progress bar */
|
||||
.list-progress {
|
||||
display: block;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.list-progress-bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #38bdf8;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.k-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
||||
import { ref, reactive, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import type { NoteType } from "@/types/note";
|
||||
import { useNotesStore } from "@/stores/notes";
|
||||
@@ -34,10 +34,85 @@ const tags = ref<string[]>([]);
|
||||
const projectId = ref<number | null>(null);
|
||||
const milestoneId = ref<number | null>(null);
|
||||
const noteType = ref<NoteType>("note");
|
||||
const entityMeta = reactive<Record<string, string>>({});
|
||||
const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
const showPreview = ref(false);
|
||||
const sidebarOpen = ref(true);
|
||||
const notesExpanded = ref(false);
|
||||
|
||||
// ── List builder ─────────────────────────────────────────────────────────────
|
||||
interface ListItem {
|
||||
text: string;
|
||||
checked: boolean;
|
||||
}
|
||||
const listItems = ref<ListItem[]>([]);
|
||||
const listItemRefs = ref<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } {
|
||||
const lines = bodyText.split('\n');
|
||||
const items: ListItem[] = [];
|
||||
const extraLines: string[] = [];
|
||||
let pastList = false;
|
||||
for (const line of lines) {
|
||||
const stripped = line.trimStart();
|
||||
if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) {
|
||||
items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') });
|
||||
} else if (!pastList && stripped === '' && items.length > 0) {
|
||||
pastList = true;
|
||||
} else {
|
||||
pastList = true;
|
||||
extraLines.push(line);
|
||||
}
|
||||
}
|
||||
return { items, extra: extraLines.join('\n').trim() };
|
||||
}
|
||||
|
||||
function serializeListToBody(): string {
|
||||
const listPart = listItems.value
|
||||
.map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
|
||||
.join('\n');
|
||||
const extraPart = body.value.trim();
|
||||
return extraPart ? `${listPart}\n\n${extraPart}` : listPart;
|
||||
}
|
||||
|
||||
function addListItem(afterIndex?: number) {
|
||||
const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length;
|
||||
listItems.value.splice(idx, 0, { text: '', checked: false });
|
||||
markDirty();
|
||||
nextTick(() => {
|
||||
listItemRefs.value[idx]?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function removeListItem(index: number) {
|
||||
if (listItems.value.length <= 1) return;
|
||||
listItems.value.splice(index, 1);
|
||||
markDirty();
|
||||
nextTick(() => {
|
||||
const focusIdx = Math.max(0, index - 1);
|
||||
listItemRefs.value[focusIdx]?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function onListItemKeydown(e: KeyboardEvent, index: number) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addListItem(index);
|
||||
} else if (e.key === 'Backspace' && listItems.value[index].text === '') {
|
||||
e.preventDefault();
|
||||
removeListItem(index);
|
||||
}
|
||||
}
|
||||
|
||||
function onListItemInput(_index: number) {
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function toggleListItemCheck(index: number) {
|
||||
listItems.value[index].checked = !listItems.value[index].checked;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
||||
const titleRef = ref<HTMLInputElement | null>(null);
|
||||
@@ -52,6 +127,9 @@ const isEditing = computed(() => noteId.value !== null);
|
||||
|
||||
const titlePlaceholder = computed(() => {
|
||||
switch (noteType.value) {
|
||||
case 'person': return 'Name';
|
||||
case 'place': return 'Place name';
|
||||
case 'list': return 'List title';
|
||||
case 'process': return 'Process name';
|
||||
default: return 'Title';
|
||||
}
|
||||
@@ -198,6 +276,7 @@ let savedTags: string[] = [];
|
||||
let savedProjectId: number | null = null;
|
||||
let savedMilestoneId: number | null = null;
|
||||
let savedNoteType: NoteType = "note";
|
||||
let savedEntityMeta: Record<string, string> = {};
|
||||
|
||||
function markDirty() {
|
||||
dirty.value =
|
||||
@@ -206,7 +285,8 @@ function markDirty() {
|
||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||
projectId.value !== savedProjectId ||
|
||||
milestoneId.value !== savedMilestoneId ||
|
||||
noteType.value !== savedNoteType;
|
||||
noteType.value !== savedNoteType ||
|
||||
JSON.stringify(entityMeta) !== JSON.stringify(savedEntityMeta);
|
||||
}
|
||||
|
||||
function onBodyUpdate(newVal: string) {
|
||||
@@ -224,19 +304,31 @@ onMounted(async () => {
|
||||
projectId.value = store.currentNote.project_id ?? null;
|
||||
milestoneId.value = store.currentNote.milestone_id ?? null;
|
||||
noteType.value = (store.currentNote.note_type as NoteType) || "note";
|
||||
Object.assign(entityMeta, store.currentNote.metadata || {});
|
||||
notesExpanded.value = !!(store.currentNote.body || '').trim();
|
||||
if (noteType.value === 'list') {
|
||||
const parsed = parseListFromBody(body.value);
|
||||
listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }];
|
||||
body.value = parsed.extra;
|
||||
notesExpanded.value = !!parsed.extra;
|
||||
}
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedNoteType = noteType.value;
|
||||
savedEntityMeta = { ...entityMeta };
|
||||
}
|
||||
} else {
|
||||
// New note: read type from query param
|
||||
const qt = route.query.type as string | undefined;
|
||||
if (qt && ["note", "process"].includes(qt)) {
|
||||
if (qt && ["note", "person", "place", "list"].includes(qt)) {
|
||||
noteType.value = qt as NoteType;
|
||||
}
|
||||
if (noteType.value === 'list') {
|
||||
listItems.value = [{ text: '', checked: false }];
|
||||
}
|
||||
}
|
||||
|
||||
// Restore pending draft if any
|
||||
@@ -257,7 +349,7 @@ onMounted(async () => {
|
||||
async function save() {
|
||||
if (saving.value) return;
|
||||
saving.value = true;
|
||||
const finalBody = body.value;
|
||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await store.updateNote(noteId.value!, {
|
||||
@@ -267,6 +359,7 @@ async function save() {
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
note_type: noteType.value,
|
||||
metadata: { ...entityMeta },
|
||||
});
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
@@ -274,6 +367,7 @@ async function save() {
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedNoteType = noteType.value;
|
||||
savedEntityMeta = { ...entityMeta };
|
||||
dirty.value = false;
|
||||
toast.show("Note saved");
|
||||
} else {
|
||||
@@ -284,6 +378,7 @@ async function save() {
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
note_type: noteType.value,
|
||||
metadata: { ...entityMeta },
|
||||
});
|
||||
dirty.value = false;
|
||||
toast.show("Note created");
|
||||
@@ -319,12 +414,12 @@ async function confirmDelete() {
|
||||
async function doAutoSave() {
|
||||
if (!isEditing.value || saving.value) return;
|
||||
saving.value = true;
|
||||
const finalBody = body.value;
|
||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
||||
try {
|
||||
await store.updateNote(noteId.value!, {
|
||||
title: title.value, body: finalBody, tags: tags.value,
|
||||
project_id: projectId.value, milestone_id: milestoneId.value,
|
||||
note_type: noteType.value,
|
||||
note_type: noteType.value, metadata: { ...entityMeta },
|
||||
});
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
@@ -332,6 +427,7 @@ async function doAutoSave() {
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedNoteType = noteType.value;
|
||||
savedEntityMeta = { ...entityMeta };
|
||||
dirty.value = false;
|
||||
toast.show("Auto-saved");
|
||||
} catch {
|
||||
@@ -377,8 +473,138 @@ onUnmounted(() => assist.clearSelection());
|
||||
|
||||
<!-- ── Main column ────────────────────────────────────────── -->
|
||||
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
|
||||
<!-- ── Person form ──────────────────────────────────────── -->
|
||||
<template v-if="noteType === 'person'">
|
||||
<div class="entity-form">
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Relationship</label>
|
||||
<input class="ef-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague, Family" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Birthday</label>
|
||||
<input class="ef-input" type="date" v-model="entityMeta.birthday" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Email</label>
|
||||
<input class="ef-input" type="email" v-model="entityMeta.email" placeholder="email@example.com" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Phone</label>
|
||||
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Organization</label>
|
||||
<input class="ef-input" v-model="entityMeta.organization" placeholder="Company or organization" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Address</label>
|
||||
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="notes-section">
|
||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
||||
</button>
|
||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Additional notes, wikilinks, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Place form ───────────────────────────────────────── -->
|
||||
<template v-else-if="noteType === 'place'">
|
||||
<div class="entity-form">
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Address</label>
|
||||
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Phone</label>
|
||||
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Hours</label>
|
||||
<input class="ef-input" v-model="entityMeta.hours" placeholder="e.g. Mon–Fri 9am–5pm" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Website</label>
|
||||
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Category</label>
|
||||
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="notes-section">
|
||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
||||
</button>
|
||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Additional notes, wikilinks, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── List builder ─────────────────────────────────────── -->
|
||||
<template v-else-if="noteType === 'list'">
|
||||
<div class="list-builder">
|
||||
<div
|
||||
v-for="(item, idx) in listItems"
|
||||
:key="idx"
|
||||
class="lb-item"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="item.checked"
|
||||
@change="toggleListItemCheck(idx)"
|
||||
class="lb-check"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<input
|
||||
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
|
||||
v-model="item.text"
|
||||
class="lb-text"
|
||||
placeholder="List item..."
|
||||
@keydown="onListItemKeydown($event, idx)"
|
||||
@input="onListItemInput(idx)"
|
||||
/>
|
||||
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">×</button>
|
||||
</div>
|
||||
<button class="lb-add" @click="addListItem()">+ Add item</button>
|
||||
</div>
|
||||
<div class="notes-section">
|
||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
||||
</button>
|
||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Additional notes, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Process (prompt) editor ──────────────────────────── -->
|
||||
<template v-if="noteType === 'process'">
|
||||
<template v-else-if="noteType === 'process'">
|
||||
<label class="ef-label">Prompt</label>
|
||||
<textarea
|
||||
class="prompt-editor"
|
||||
@@ -492,7 +718,9 @@ onUnmounted(() => assist.clearSelection());
|
||||
<label class="sb-label">Type</label>
|
||||
<select v-model="noteType" class="sb-select" @change="markDirty">
|
||||
<option value="note">Note</option>
|
||||
<option value="process">Process</option>
|
||||
<option value="person">Person</option>
|
||||
<option value="place">Place</option>
|
||||
<option value="list">List</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -806,7 +1034,18 @@ onUnmounted(() => assist.clearSelection());
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ── Process editor ─────────────────────────────────────── */
|
||||
/* ── Entity form (Person / Place) ───────────────────────── */
|
||||
.entity-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
.ef-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.ef-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 0.92rem;
|
||||
@@ -832,6 +1071,119 @@ onUnmounted(() => assist.clearSelection());
|
||||
.prompt-editor:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.ef-input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.ef-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.ef-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Notes section (collapsible TipTap) ─────────────────── */
|
||||
.notes-section {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.notes-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.notes-toggle:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
.notes-editor-wrap {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ── List builder ───────────────────────────────────────── */
|
||||
.list-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.lb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.lb-check {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.lb-text {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.lb-text:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.lb-text::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.lb-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, color 0.12s;
|
||||
}
|
||||
.lb-item:hover .lb-delete,
|
||||
.lb-text:focus ~ .lb-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
.lb-delete:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.lb-add {
|
||||
background: none;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.lb-add:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Narrow screen: sidebar collapses */
|
||||
@media (max-width: 720px) {
|
||||
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
||||
|
||||
@@ -366,6 +366,18 @@ const notifySecurityAlerts = ref(true);
|
||||
const savingNotifications = ref(false);
|
||||
const notificationsSaved = ref(false);
|
||||
|
||||
// CalDAV settings (per-user)
|
||||
const caldav = ref({
|
||||
caldav_url: "",
|
||||
caldav_username: "",
|
||||
caldav_password: "",
|
||||
caldav_calendar_name: "",
|
||||
});
|
||||
const savingCaldav = ref(false);
|
||||
const caldavSaved = ref(false);
|
||||
const testingCaldav = ref(false);
|
||||
const caldavTestResult = ref<{ success: boolean; message?: string; error?: string; calendars?: string[] } | null>(null);
|
||||
|
||||
// SMTP settings (admin only)
|
||||
const smtp = ref({
|
||||
smtp_host: "",
|
||||
@@ -471,6 +483,14 @@ onMounted(async () => {
|
||||
|
||||
// Load journal config (locations, temp unit; prep/closeout UI removed in Phase 7)
|
||||
|
||||
// Load CalDAV settings
|
||||
try {
|
||||
const caldavConfig = await apiGet<Record<string, string>>("/api/settings/caldav");
|
||||
caldav.value = { ...caldav.value, ...caldavConfig };
|
||||
} catch {
|
||||
// CalDAV not configured yet
|
||||
}
|
||||
|
||||
// Check SearXNG status
|
||||
try {
|
||||
const sr = await apiGet<{ configured: boolean; searxng_url: string }>("/api/settings/search");
|
||||
@@ -652,6 +672,38 @@ async function saveNotifications() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCaldav() {
|
||||
savingCaldav.value = true;
|
||||
caldavSaved.value = false;
|
||||
try {
|
||||
await apiPut("/api/settings/caldav", caldav.value);
|
||||
caldavSaved.value = true;
|
||||
setTimeout(() => (caldavSaved.value = false), 2000);
|
||||
} catch {
|
||||
toastStore.show("Failed to save CalDAV settings", "error");
|
||||
} finally {
|
||||
savingCaldav.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testCaldav() {
|
||||
testingCaldav.value = true;
|
||||
caldavTestResult.value = null;
|
||||
try {
|
||||
const result = await apiPost<{ success: boolean; message?: string; error?: string; calendars?: string[] }>("/api/settings/caldav/test", {});
|
||||
caldavTestResult.value = result;
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === "object" && "body" in e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
caldavTestResult.value = { success: false, error: body?.error || "Connection test failed" };
|
||||
} else {
|
||||
caldavTestResult.value = { success: false, error: "Connection test failed" };
|
||||
}
|
||||
} finally {
|
||||
testingCaldav.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSmtp() {
|
||||
savingSmtp.value = true;
|
||||
smtpSaved.value = false;
|
||||
@@ -1455,6 +1507,50 @@ function formatUserDate(iso: string): string {
|
||||
<!-- ── Integrations ── -->
|
||||
<div v-show="activeTab === 'integrations'" class="settings-grid">
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Calendar (CalDAV)</h2>
|
||||
<p class="section-desc">
|
||||
Connect to a CalDAV server (Nextcloud, iCloud, Radicale, Baikal) to create and view calendar events from chat.
|
||||
</p>
|
||||
<div class="caldav-grid">
|
||||
<div class="field">
|
||||
<label for="caldav-url">CalDAV URL</label>
|
||||
<input id="caldav-url" v-model="caldav.caldav_url" type="url" placeholder="https://cloud.example.com/remote.php/dav" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="caldav-username">Username</label>
|
||||
<input id="caldav-username" v-model="caldav.caldav_username" type="text" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="caldav-password">Password</label>
|
||||
<input id="caldav-password" v-model="caldav.caldav_password" type="password" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="caldav-calendar-name">Calendar Name</label>
|
||||
<input id="caldav-calendar-name" v-model="caldav.caldav_calendar_name" type="text" placeholder="Leave empty to use first available" class="input" />
|
||||
<p class="field-hint">Optional. Exact calendar name to use.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" style="margin-bottom: 1rem;">
|
||||
<button class="btn-save" @click="saveCaldav" :disabled="savingCaldav">
|
||||
{{ savingCaldav ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<span v-if="caldavSaved" class="saved-msg">Saved!</span>
|
||||
<button class="btn-secondary" @click="testCaldav" :disabled="testingCaldav">
|
||||
{{ testingCaldav ? "Testing..." : "Test Connection" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="caldavTestResult" class="test-result" :class="{ success: caldavTestResult.success, error: !caldavTestResult.success }">
|
||||
<template v-if="caldavTestResult.success">
|
||||
{{ caldavTestResult.message }}
|
||||
<div v-if="caldavTestResult.calendars?.length" class="test-calendars">
|
||||
Calendars: {{ caldavTestResult.calendars.join(", ") }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>{{ caldavTestResult.error }}</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Web Search (SearXNG)</h2>
|
||||
<template v-if="searxngConfigured">
|
||||
@@ -2616,6 +2712,41 @@ function formatUserDate(iso: string): string {
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* CalDAV grid */
|
||||
.caldav-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
gap: 0 1rem;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.caldav-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.caldav-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.test-result {
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.test-result.success {
|
||||
background: color-mix(in srgb, var(--color-success) 10%, var(--color-bg));
|
||||
border: 1px solid var(--color-success);
|
||||
color: var(--color-success);
|
||||
}
|
||||
.test-result.error {
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, var(--color-bg));
|
||||
border: 1px solid var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.test-calendars {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Search test */
|
||||
.url-chip {
|
||||
font-size: 0.8rem;
|
||||
@@ -2800,6 +2931,9 @@ function formatUserDate(iso: string): string {
|
||||
.smtp-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.caldav-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Users panel */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "scribe",
|
||||
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||
"version": "0.1.12",
|
||||
"description": "Scribe second brain for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||
"version": "0.1.10",
|
||||
"author": { "name": "Bryan Van Deusen" },
|
||||
"mcpServers": {
|
||||
"scribe": {
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@
|
||||
Turns a self-hosted [Scribe](https://git.fabledsword.com/bvandeusen/FabledScribe)
|
||||
instance into a first-class Claude Code extension:
|
||||
|
||||
- **MCP tools** over your notes, tasks, projects, milestones, systems, and
|
||||
rulebook (the `scribe` server).
|
||||
- **MCP tools** over your notes, tasks, projects, milestones, events, typed
|
||||
entities, and rulebook (the `scribe` server).
|
||||
- **Session-start push channel** — a `SessionStart` hook injects your always-on
|
||||
rules + active-project context so Scribe surfaces *without being asked*.
|
||||
- **Universal process-skills** — brainstorm, systematic-debugging, TDD,
|
||||
|
||||
@@ -90,7 +90,7 @@ fi
|
||||
|
||||
# Compaction re-grounding: lead with a reload banner when this fire is a compact.
|
||||
if [ "$source" = "compact" ]; then
|
||||
prepend "> ⟳ This session was just COMPACTED — earlier turns are now a summary, so in-flight detail may be lost. Before continuing, reload your bearings from Scribe: re-pull the operator's binding rules with \`list_always_on_rules()\` (a compaction can summarize them out of context, leaving only generic harness defaults in their place), re-run \`enter_project()\` for the active project, check its open tasks and recent notes, and reconcile what you're mid-way through against what Scribe records. Don't trust half-remembered state — Scribe is the record."
|
||||
prepend "> ⟳ This session was just COMPACTED — earlier turns are now a summary, so in-flight detail may be lost. Before continuing, reload your bearings from Scribe: re-run \`enter_project()\` for the active project, check its open tasks and recent notes, and reconcile what you're mid-way through against what Scribe records. Don't trust half-remembered state — Scribe is the record."
|
||||
fi
|
||||
|
||||
# Nothing at all to inject → stay silent.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Scribe — your system of record
|
||||
# Scribe — your second brain and system of record
|
||||
|
||||
This environment has the **Scribe** plugin: the operator's self-hosted system
|
||||
of record (notes, tasks, projects, milestones, rules) reachable through the
|
||||
This environment has the **Scribe** plugin: the operator's self-hosted second
|
||||
brain (notes, tasks, projects, milestones, rules) reachable through the
|
||||
`scribe` MCP tools. Treat Scribe — **not local files** — as the source of truth
|
||||
for the operator's work, and as your own working memory across sessions.
|
||||
|
||||
@@ -12,15 +12,6 @@ for the operator's work, and as your own working memory across sessions.
|
||||
recent notes in one shot.
|
||||
|
||||
**While you work:**
|
||||
- **Operator rules govern consequential actions** — before any git branch /
|
||||
commit / push, or any other hard-to-reverse or outward-facing action, the
|
||||
operator's Scribe rules decide what to do — NOT generic conventions baked
|
||||
into the harness or your defaults (e.g. "branch before committing," "open a
|
||||
feature branch per task," "push to a fork"). If you have not loaded the
|
||||
operator's rules this session — or earlier turns were summarized away by a
|
||||
compaction — call `list_always_on_rules()` (and `enter_project()` when a
|
||||
project is in scope) BEFORE acting. When a loaded rule and a default habit
|
||||
disagree, the rule wins; if no rule speaks to it, ask rather than assume.
|
||||
- **Recall before acting** — before you answer anything about the operator's
|
||||
work or start a task, `search` Scribe first; assume a related note, task, or
|
||||
decision already exists. Concretely, reach for recall whenever a request
|
||||
|
||||
@@ -5,8 +5,8 @@ description: Use at the START of every session, and before answering anything ab
|
||||
|
||||
# Using Scribe
|
||||
|
||||
Scribe is the operator's self-hosted system of record (notes, tasks, issues,
|
||||
projects, milestones, systems) and rulebook, reachable
|
||||
Scribe is the operator's self-hosted second brain (notes, tasks, issues,
|
||||
projects, milestones, systems, events, typed entities) and rulebook, reachable
|
||||
through the bundled `scribe` MCP server. Its value is mostly in what it
|
||||
**already holds** — so make reading it a reflex, not something you wait to be
|
||||
asked for.
|
||||
|
||||
+7
-5
@@ -21,6 +21,7 @@ from scribe.routes.shares import shares_bp
|
||||
from scribe.routes.in_app_notifications import notifications_bp
|
||||
from scribe.routes.users import users_bp
|
||||
from scribe.routes.api_keys import api_keys_bp
|
||||
from scribe.routes.events import events_bp
|
||||
from scribe.routes.search import search_bp
|
||||
from scribe.routes.profile import profile_bp
|
||||
from scribe.routes.knowledge import knowledge_bp
|
||||
@@ -83,6 +84,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(notifications_bp)
|
||||
app.register_blueprint(users_bp)
|
||||
app.register_blueprint(api_keys_bp)
|
||||
app.register_blueprint(events_bp)
|
||||
app.register_blueprint(search_bp)
|
||||
app.register_blueprint(profile_bp)
|
||||
app.register_blueprint(knowledge_bp)
|
||||
@@ -171,9 +173,9 @@ def create_app() -> Quart:
|
||||
|
||||
asyncio.create_task(_delayed_backfill())
|
||||
|
||||
# Recurrence scheduler (recurring-task spawn every 15m)
|
||||
from scribe.services.recurrence_scheduler import start_recurrence_scheduler
|
||||
start_recurrence_scheduler(asyncio.get_running_loop())
|
||||
# Event scheduler (reminders + CalDAV pull sync)
|
||||
from scribe.services.event_scheduler import start_event_scheduler
|
||||
start_event_scheduler(asyncio.get_running_loop())
|
||||
|
||||
# Version-pinning scheduler (daily auto-pin scan at 03:00 UTC)
|
||||
from scribe.services.version_pinning_scheduler import (
|
||||
@@ -202,8 +204,8 @@ def create_app() -> Quart:
|
||||
|
||||
@app.after_serving
|
||||
async def shutdown():
|
||||
from scribe.services.recurrence_scheduler import stop_recurrence_scheduler
|
||||
stop_recurrence_scheduler()
|
||||
from scribe.services.event_scheduler import stop_event_scheduler
|
||||
stop_event_scheduler()
|
||||
from scribe.services.version_pinning_scheduler import (
|
||||
stop_version_pinning_scheduler,
|
||||
)
|
||||
|
||||
@@ -45,10 +45,15 @@ What each part is for, and when to reach for it:
|
||||
record (note, task, issue) with it via system_ids so research, build-work, and
|
||||
fixes for the same area line up, and recurring problem-spots surface. Manage
|
||||
with create_system / list_systems / get_system.
|
||||
- Typed entities (person/place/list): structured records about people, places,
|
||||
and checklists.
|
||||
|
||||
Mechanics:
|
||||
- Notes and Tasks share a model; tasks are notes with is_task=True.
|
||||
- Use the *_note tools for notes, the *_task tools for tasks. Don't mix them.
|
||||
- Typed entities (person, place, list) are notes with a non-default note_type
|
||||
plus type-specific columns; use the dedicated *_person / *_place / *_list
|
||||
tools rather than create_note.
|
||||
- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave
|
||||
unchanged on updates.
|
||||
- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean
|
||||
@@ -219,9 +224,10 @@ operator. "Works for one user" is not done.
|
||||
# write for read keys (default-deny), so a newly-added tool is locked down
|
||||
# until explicitly classified here.
|
||||
_READ_ONLY_TOOLS = frozenset({
|
||||
"get_note", "get_project", "get_rule", "get_rulebook",
|
||||
"get_event", "get_note", "get_project", "get_rule", "get_rulebook",
|
||||
"get_task", "get_milestone", "get_recent", "enter_project",
|
||||
"list_milestones", "list_notes", "list_projects", "list_rulebooks",
|
||||
"list_events", "list_lists", "list_milestones", "list_notes",
|
||||
"list_persons", "list_places", "list_projects", "list_rulebooks",
|
||||
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
|
||||
"list_always_on_rules", "search",
|
||||
"get_system", "list_systems", "list_system_records",
|
||||
|
||||
@@ -5,7 +5,7 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
|
||||
from `mcp.server.build_mcp_server`.
|
||||
"""
|
||||
from scribe.mcp.tools import (
|
||||
milestones, notes, processes, projects, recent, repos, rulebooks, search, systems, tags, tasks, trash,
|
||||
entities, events, milestones, notes, processes, projects, recent, repos, rulebooks, search, systems, tags, tasks, trash,
|
||||
)
|
||||
|
||||
|
||||
@@ -17,8 +17,10 @@ def register_all(mcp) -> None:
|
||||
projects.register(mcp)
|
||||
milestones.register(mcp)
|
||||
systems.register(mcp)
|
||||
events.register(mcp)
|
||||
tags.register(mcp)
|
||||
recent.register(mcp)
|
||||
entities.register(mcp)
|
||||
repos.register(mcp)
|
||||
processes.register(mcp)
|
||||
rulebooks.register(mcp)
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Typed-entity MCP tools: person, place, list.
|
||||
|
||||
These are notes with a non-'note' note_type and type-specific JSON metadata
|
||||
stored in the entity_meta column. Three tools per type — list, create, update.
|
||||
For get and delete, use get_note / delete_note (typed entities
|
||||
share the Note model).
|
||||
|
||||
The wrappers translate typed-field kwargs into the entity_meta dict shape that
|
||||
KnowledgeView.vue and services/knowledge.py expect.
|
||||
|
||||
Lists: a list entity stores its items in entity_meta["list_items"] as a list
|
||||
of {text, checked} dicts. The create/update tools take a simpler `items` list
|
||||
of plain strings for ergonomics; checked-state is reset to False on each call.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import knowledge as knowledge_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
|
||||
|
||||
async def _list_by_type(note_type: str, q: str, tag: str, limit: int) -> dict:
|
||||
"""Common list query for a typed entity."""
|
||||
uid = current_user_id()
|
||||
items, total = await knowledge_svc.query_knowledge(
|
||||
user_id=uid,
|
||||
note_type=note_type,
|
||||
tags=[tag] if tag else [],
|
||||
sort="modified",
|
||||
q=q or None,
|
||||
limit=max(1, min(limit, 100)),
|
||||
offset=0,
|
||||
)
|
||||
# Map "items" key to a type-specific key for caller clarity.
|
||||
plural = {"person": "persons", "place": "places", "list": "lists"}[note_type]
|
||||
return {plural: items, "total": total}
|
||||
|
||||
|
||||
async def _create_entity(note_type: str, name: str, entity_meta: dict,
|
||||
tags: list[str] | None = None) -> dict:
|
||||
"""Create a typed note with the given metadata."""
|
||||
uid = current_user_id()
|
||||
note = await notes_svc.create_note(
|
||||
uid,
|
||||
title=name,
|
||||
note_type=note_type,
|
||||
entity_meta=entity_meta or None,
|
||||
tags=tags,
|
||||
)
|
||||
return note.to_dict()
|
||||
|
||||
|
||||
async def _update_entity(entity_id: int, note_type: str, name: str,
|
||||
meta_updates: dict) -> dict:
|
||||
"""Merge updates into entity_meta and (optionally) update the title."""
|
||||
uid = current_user_id()
|
||||
note = await notes_svc.get_note(uid, entity_id)
|
||||
if note is None or note.note_type != note_type:
|
||||
raise ValueError(f"{note_type} {entity_id} not found")
|
||||
new_meta = dict(note.entity_meta or {})
|
||||
new_meta.update(meta_updates)
|
||||
fields: dict = {"entity_meta": new_meta}
|
||||
if name:
|
||||
fields["title"] = name
|
||||
updated = await notes_svc.update_note(uid, entity_id, **fields)
|
||||
return updated.to_dict()
|
||||
|
||||
|
||||
# ─── Person ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_PERSON_FIELDS = ("relationship", "email", "phone", "birthday",
|
||||
"organization", "address")
|
||||
|
||||
|
||||
async def list_persons(q: str = "", tag: str = "", limit: int = 25) -> dict:
|
||||
"""List people in the user's knowledge base.
|
||||
|
||||
Args:
|
||||
q: Free-text search across name + body (optional).
|
||||
tag: Filter to a single tag (optional).
|
||||
limit: Max results (1-100).
|
||||
"""
|
||||
return await _list_by_type("person", q, tag, limit)
|
||||
|
||||
|
||||
async def create_person(
|
||||
name: str,
|
||||
relationship: str = "",
|
||||
email: str = "",
|
||||
phone: str = "",
|
||||
birthday: str = "",
|
||||
organization: str = "",
|
||||
address: str = "",
|
||||
tags: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Create a person in the user's knowledge base.
|
||||
|
||||
Args:
|
||||
name: Person's name (required).
|
||||
relationship: How the user knows them (e.g. "colleague", "friend").
|
||||
email / phone / birthday (YYYY-MM-DD) / organization / address: optional.
|
||||
tags: Plain-string tags, no # prefix.
|
||||
"""
|
||||
meta = {f: v for f, v in (
|
||||
("relationship", relationship), ("email", email), ("phone", phone),
|
||||
("birthday", birthday), ("organization", organization),
|
||||
("address", address),
|
||||
) if v}
|
||||
return await _create_entity("person", name, meta, tags)
|
||||
|
||||
|
||||
async def update_person(
|
||||
person_id: int,
|
||||
name: str = "",
|
||||
relationship: str = "",
|
||||
email: str = "",
|
||||
phone: str = "",
|
||||
birthday: str = "",
|
||||
organization: str = "",
|
||||
address: str = "",
|
||||
) -> dict:
|
||||
"""Update a person. Only explicitly provided fields are changed.
|
||||
|
||||
To clear a field, pass an explicit space character (this preserves the
|
||||
fable-mcp empty-string-means-omit convention).
|
||||
"""
|
||||
meta_updates = {f: v for f, v in (
|
||||
("relationship", relationship), ("email", email), ("phone", phone),
|
||||
("birthday", birthday), ("organization", organization),
|
||||
("address", address),
|
||||
) if v}
|
||||
return await _update_entity(person_id, "person", name, meta_updates)
|
||||
|
||||
|
||||
# ─── Place ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def list_places(q: str = "", tag: str = "", limit: int = 25) -> dict:
|
||||
"""List places (cafes, offices, addresses) in the user's knowledge base."""
|
||||
return await _list_by_type("place", q, tag, limit)
|
||||
|
||||
|
||||
async def create_place(
|
||||
name: str,
|
||||
address: str = "",
|
||||
phone: str = "",
|
||||
hours: str = "",
|
||||
website: str = "",
|
||||
category: str = "",
|
||||
tags: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Create a place in the user's knowledge base.
|
||||
|
||||
Args:
|
||||
name: Place name (required).
|
||||
address / phone / hours / website / category: optional.
|
||||
tags: Plain-string tags, no # prefix.
|
||||
"""
|
||||
meta = {f: v for f, v in (
|
||||
("address", address), ("phone", phone), ("hours", hours),
|
||||
("website", website), ("category", category),
|
||||
) if v}
|
||||
return await _create_entity("place", name, meta, tags)
|
||||
|
||||
|
||||
async def update_place(
|
||||
place_id: int,
|
||||
name: str = "",
|
||||
address: str = "",
|
||||
phone: str = "",
|
||||
hours: str = "",
|
||||
website: str = "",
|
||||
category: str = "",
|
||||
) -> dict:
|
||||
"""Update a place. Only explicitly provided fields are changed."""
|
||||
meta_updates = {f: v for f, v in (
|
||||
("address", address), ("phone", phone), ("hours", hours),
|
||||
("website", website), ("category", category),
|
||||
) if v}
|
||||
return await _update_entity(place_id, "place", name, meta_updates)
|
||||
|
||||
|
||||
# ─── List ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def list_lists(q: str = "", tag: str = "", limit: int = 25) -> dict:
|
||||
"""List checklists in the user's knowledge base."""
|
||||
return await _list_by_type("list", q, tag, limit)
|
||||
|
||||
|
||||
async def create_list(
|
||||
name: str,
|
||||
category: str = "",
|
||||
items: list[str] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Create a checklist (a list-type entity).
|
||||
|
||||
Args:
|
||||
name: List name (required).
|
||||
category: Optional category label (e.g. "shopping", "packing").
|
||||
items: Initial item texts. All items start unchecked.
|
||||
tags: Plain-string tags, no # prefix.
|
||||
"""
|
||||
meta: dict = {}
|
||||
if category:
|
||||
meta["category"] = category
|
||||
if items:
|
||||
meta["list_items"] = [{"text": t, "checked": False} for t in items]
|
||||
return await _create_entity("list", name, meta, tags)
|
||||
|
||||
|
||||
async def update_list(
|
||||
list_id: int,
|
||||
name: str = "",
|
||||
category: str = "",
|
||||
items: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Update a checklist.
|
||||
|
||||
Args:
|
||||
list_id: ID of the list to update.
|
||||
name: New title (optional).
|
||||
category: New category (optional).
|
||||
items: REPLACES the entire item set with these texts (all reset to
|
||||
unchecked). Omit (None) to leave items unchanged. Pass an empty
|
||||
list to clear all items.
|
||||
"""
|
||||
meta_updates: dict = {}
|
||||
if category:
|
||||
meta_updates["category"] = category
|
||||
if items is not None:
|
||||
meta_updates["list_items"] = [
|
||||
{"text": t, "checked": False} for t in items
|
||||
]
|
||||
return await _update_entity(list_id, "list", name, meta_updates)
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
list_persons, create_person, update_person,
|
||||
list_places, create_place, update_place,
|
||||
list_lists, create_list, update_list,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Calendar event MCP tools — new in Phase 3.
|
||||
|
||||
Events were previously only an internal-LLM tool; the MCP surface didn't have
|
||||
them. Wraps services/events.py.
|
||||
|
||||
Date/time inputs are split: start_date (YYYY-MM-DD) + start_time (HH:MM) get
|
||||
combined into a naive datetime; the service layer interprets it in the user's
|
||||
local timezone. duration_minutes=0 ⇒ point event (NULL duration). The
|
||||
LLM-era expected_weekday verification check is intentionally not replicated —
|
||||
Claude doesn't need it.
|
||||
|
||||
For update, sentinels:
|
||||
- title="" / location="" / description="" → leave unchanged
|
||||
- start_date="" / start_time="" → leave unchanged (both must be provided to
|
||||
move the event)
|
||||
- duration_minutes=-1 → leave unchanged; 0 means "set to point event"
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import events as events_svc
|
||||
from scribe.services import trash as trash_svc
|
||||
|
||||
|
||||
def _combine(start_date: str, start_time: str) -> datetime:
|
||||
"""Combine YYYY-MM-DD + HH:MM into a naive datetime.
|
||||
|
||||
The events service interprets naive datetimes for create/update against
|
||||
the user's configured timezone, so we don't attach tzinfo here.
|
||||
"""
|
||||
t = start_time or "00:00"
|
||||
return datetime.fromisoformat(f"{start_date}T{t}:00")
|
||||
|
||||
|
||||
def _day_range_utc(date_from: str, date_to: str) -> tuple[datetime, datetime]:
|
||||
"""Return a UTC datetime range [start_of_date_from, end_of_date_to).
|
||||
|
||||
Event.start_dt is stored timezone-aware in the DB; comparing it against a
|
||||
naive datetime raises TypeError. We anchor the range in UTC, which is a
|
||||
reasonable default — refining to the user's local timezone for the
|
||||
range boundaries is a separate improvement.
|
||||
"""
|
||||
start = datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)
|
||||
# `date_to` is inclusive at the day level — bump by 24h so events later
|
||||
# on date_to are included.
|
||||
end = (
|
||||
datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc)
|
||||
+ timedelta(days=1)
|
||||
)
|
||||
return start, end
|
||||
|
||||
|
||||
def _event_dict(event) -> dict:
|
||||
"""Render an Event model to a dict, handling list_events (already dicts)."""
|
||||
return event if isinstance(event, dict) else event.to_dict()
|
||||
|
||||
|
||||
async def list_events(date_from: str, date_to: str) -> dict:
|
||||
"""List events between date_from and date_to (YYYY-MM-DD, both inclusive at
|
||||
the day level — `date_to` is interpreted as end-of-that-day).
|
||||
|
||||
Recurring events are expanded into individual occurrences within the range.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
start, end = _day_range_utc(date_from, date_to)
|
||||
rows = await events_svc.list_events(uid, start, end)
|
||||
return {"events": [_event_dict(e) for e in rows], "total": len(rows)}
|
||||
|
||||
|
||||
async def create_event(
|
||||
title: str,
|
||||
start_date: str,
|
||||
start_time: str = "00:00",
|
||||
duration_minutes: int = 0,
|
||||
all_day: bool = False,
|
||||
location: str = "",
|
||||
description: str = "",
|
||||
) -> dict:
|
||||
"""Create a calendar event.
|
||||
|
||||
Args:
|
||||
title: Event title (required).
|
||||
start_date: YYYY-MM-DD.
|
||||
start_time: HH:MM (24-hour). Ignored when all_day=True.
|
||||
duration_minutes: 0 for a point event (no duration); otherwise minutes.
|
||||
all_day: True to make this an all-day event.
|
||||
location: Optional location string.
|
||||
description: Optional longer description.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
event = await events_svc.create_event(
|
||||
uid,
|
||||
title=title,
|
||||
start_dt=_combine(start_date, start_time),
|
||||
duration_minutes=duration_minutes or None,
|
||||
all_day=all_day,
|
||||
location=location,
|
||||
description=description,
|
||||
)
|
||||
return event.to_dict()
|
||||
|
||||
|
||||
async def get_event(event_id: int) -> dict:
|
||||
"""Fetch a single event by ID."""
|
||||
uid = current_user_id()
|
||||
event = await events_svc.get_event(uid, event_id)
|
||||
if event is None:
|
||||
raise ValueError(f"event {event_id} not found")
|
||||
return event.to_dict()
|
||||
|
||||
|
||||
async def update_event(
|
||||
event_id: int,
|
||||
title: str = "",
|
||||
start_date: str = "",
|
||||
start_time: str = "",
|
||||
duration_minutes: int = -1,
|
||||
location: str = "",
|
||||
description: str = "",
|
||||
) -> dict:
|
||||
"""Update an event. Only explicitly provided fields are changed.
|
||||
|
||||
Args:
|
||||
event_id: ID of the event to update.
|
||||
title: New title; omit to leave unchanged.
|
||||
start_date / start_time: BOTH must be set to move the event. Omit either
|
||||
to leave the start_dt unchanged.
|
||||
duration_minutes: -1 leaves unchanged; 0 sets to point event; any
|
||||
positive value sets the duration.
|
||||
location / description: omit to leave unchanged.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
fields: dict = {}
|
||||
if title:
|
||||
fields["title"] = title
|
||||
if location:
|
||||
fields["location"] = location
|
||||
if description:
|
||||
fields["description"] = description
|
||||
if start_date and start_time:
|
||||
fields["start_dt"] = _combine(start_date, start_time)
|
||||
if duration_minutes >= 0:
|
||||
# 0 means point event (NULL); positive sets a real duration.
|
||||
fields["duration_minutes"] = duration_minutes or None
|
||||
event = await events_svc.update_event(uid, event_id, **fields)
|
||||
if event is None:
|
||||
raise ValueError(f"event {event_id} not found")
|
||||
return event.to_dict()
|
||||
|
||||
|
||||
async def delete_event(event_id: int) -> dict:
|
||||
"""Move a calendar event to the trash (recoverable). Restore via restore(batch_id)."""
|
||||
uid = current_user_id()
|
||||
batch = await trash_svc.delete(uid, "event", event_id)
|
||||
if batch is None:
|
||||
raise ValueError(f"event {event_id} not found")
|
||||
return {"deleted_batch_id": batch,
|
||||
"message": f"Event {event_id} moved to trash. Restore with restore('{batch}')."}
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
list_events,
|
||||
create_event,
|
||||
get_event,
|
||||
update_event,
|
||||
delete_event,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
@@ -1,10 +1,10 @@
|
||||
"""get_recent — cross-type recent-activity tool.
|
||||
|
||||
Returns the most-recently-touched notes, tasks, and projects for the user,
|
||||
ordered by updated_at descending. Useful for Claude to bootstrap context at
|
||||
the start of a conversation ("what was I working on?").
|
||||
Returns the most-recently-touched notes, tasks, projects, and events for the
|
||||
user, ordered by updated_at descending. Useful for Claude to bootstrap context
|
||||
at the start of a conversation ("what was I working on?").
|
||||
|
||||
Aggregation is Python-side after two small per-table queries — simpler than
|
||||
Aggregation is Python-side after three small per-table queries — simpler than
|
||||
a UNION ALL with type-discriminating columns, and fine for personal-scale data.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -15,12 +15,13 @@ from sqlalchemy import select
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.models import async_session
|
||||
from scribe.models.event import Event
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project
|
||||
|
||||
|
||||
async def get_recent(days: int = 7, limit: int = 25) -> dict:
|
||||
"""Return recently-touched items across notes, tasks, and projects.
|
||||
"""Return recently-touched items across notes, tasks, projects, events.
|
||||
|
||||
Args:
|
||||
days: Look-back window in days (1-90).
|
||||
@@ -65,6 +66,19 @@ async def get_recent(days: int = 7, limit: int = 25) -> dict:
|
||||
"title": p.title,
|
||||
"updated_at": p.updated_at.isoformat(),
|
||||
})
|
||||
events = (await session.execute(
|
||||
select(Event).where(Event.user_id == uid,
|
||||
Event.updated_at >= since,
|
||||
Event.deleted_at.is_(None))
|
||||
.order_by(Event.updated_at.desc()).limit(limit)
|
||||
)).scalars().all()
|
||||
for e in events:
|
||||
items.append({
|
||||
"id": e.id,
|
||||
"type": "event",
|
||||
"title": e.title,
|
||||
"updated_at": e.updated_at.isoformat(),
|
||||
})
|
||||
items.sort(key=lambda r: r["updated_at"], reverse=True)
|
||||
items = items[:limit]
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
@@ -28,6 +28,7 @@ from scribe.models.invitation import InvitationToken # noqa: E402, F401
|
||||
from scribe.models.embedding import NoteEmbedding # noqa: E402, F401
|
||||
from scribe.models.retrieval_log import RetrievalLog # noqa: E402, F401
|
||||
from scribe.models.project import Project # noqa: E402, F401
|
||||
from scribe.models.event import Event # noqa: E402, F401
|
||||
from scribe.models.milestone import Milestone # noqa: E402, F401
|
||||
from scribe.models.task_log import TaskLog # noqa: E402, F401
|
||||
from scribe.models.note_draft import NoteDraft # noqa: E402, F401
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from scribe.models import Base
|
||||
from scribe.models.base import SoftDeleteMixin
|
||||
|
||||
|
||||
class Event(Base, SoftDeleteMixin):
|
||||
__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))
|
||||
# Duration in minutes; NULL = point event with no end specified.
|
||||
# Replaces the prior `end_dt` column (Fable #160 / migration 0043).
|
||||
# The DB has a CHECK constraint that this is NULL or >= 0, so an
|
||||
# event whose end is before its start is structurally inexpressible.
|
||||
duration_minutes: Mapped[int | None] = mapped_column(Integer, 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="")
|
||||
caldav_uid: Mapped[str] = mapped_column(Text, default="")
|
||||
color: Mapped[str] = mapped_column(Text, default="")
|
||||
recurrence: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reminder_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
reminder_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), 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),
|
||||
)
|
||||
|
||||
@property
|
||||
def end_dt(self) -> datetime | None:
|
||||
"""Derived end datetime: ``start_dt + duration_minutes``.
|
||||
|
||||
Returns ``None`` for point events (``duration_minutes is None``).
|
||||
Computed at access time rather than stored — a stored end was
|
||||
the source of the "end before start" corruption that motivated
|
||||
this redesign.
|
||||
"""
|
||||
if self.duration_minutes is None:
|
||||
return None
|
||||
return self.start_dt + timedelta(minutes=self.duration_minutes)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
end_dt = self.end_dt
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"uid": self.uid,
|
||||
"caldav_uid": self.caldav_uid,
|
||||
"project_id": self.project_id,
|
||||
"title": self.title,
|
||||
"start_dt": self.start_dt.isoformat() if self.start_dt else None,
|
||||
"end_dt": end_dt.isoformat() if end_dt else None,
|
||||
"duration_minutes": self.duration_minutes,
|
||||
"all_day": self.all_day,
|
||||
"description": self.description,
|
||||
"location": self.location,
|
||||
"color": self.color,
|
||||
"recurrence": self.recurrence,
|
||||
"reminder_minutes": self.reminder_minutes,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -61,9 +61,11 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
||||
recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# Note type — 'note' (default) or 'process' (a stored process). Task-ness is
|
||||
# tracked by `status`, not here. (person/place/list entity types removed 2026-07.)
|
||||
# Entity type — 'note' (default), 'person', 'place', 'list'
|
||||
note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note")
|
||||
# Structured metadata for entity types (person/place/list)
|
||||
# Named 'entity_meta' to avoid collision with SQLAlchemy's reserved 'metadata' attribute
|
||||
entity_meta: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True)
|
||||
# Task sub-kind — 'work' (default), 'plan', or 'issue' (corrective work).
|
||||
# Only meaningful when the note is a task (status is not None); ordinary
|
||||
# notes keep the 'work' default and ignore it. Orthogonal to note_type
|
||||
@@ -85,6 +87,11 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
||||
def is_task(self) -> bool:
|
||||
return self.status is not None
|
||||
|
||||
@property
|
||||
def entity_type(self) -> str:
|
||||
"""Normalised type: 'note', 'person', 'place', or 'list'."""
|
||||
return self.note_type or "note"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
@@ -111,8 +118,9 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
||||
else None
|
||||
),
|
||||
"is_task": self.is_task,
|
||||
"note_type": self.note_type or "note",
|
||||
"note_type": self.entity_type,
|
||||
"task_kind": self.task_kind,
|
||||
"metadata": self.entity_meta or {},
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Calendar events REST API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from scribe.auth import login_required
|
||||
import scribe.services.events as events_svc
|
||||
|
||||
events_bp = Blueprint("events", __name__, url_prefix="/api/events")
|
||||
|
||||
|
||||
def _parse_dt(value: str) -> datetime:
|
||||
"""Parse ISO 8601 datetime string, ensuring UTC-awareness."""
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def _get_current_user_id() -> int:
|
||||
return g.user.id
|
||||
|
||||
|
||||
@events_bp.get("")
|
||||
@login_required
|
||||
async def list_events():
|
||||
date_from_str = request.args.get("from")
|
||||
date_to_str = request.args.get("to")
|
||||
if not date_from_str or not date_to_str:
|
||||
return jsonify({"error": "from and to query params are required"}), 400
|
||||
try:
|
||||
date_from = _parse_dt(date_from_str)
|
||||
date_to = _parse_dt(date_to_str)
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid datetime format"}), 400
|
||||
events = await events_svc.list_events(
|
||||
user_id=_get_current_user_id(),
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
return jsonify(events)
|
||||
|
||||
|
||||
@events_bp.post("")
|
||||
@login_required
|
||||
async def create_event():
|
||||
data = await request.get_json() or {}
|
||||
if not data.get("title") or not data.get("start_dt"):
|
||||
return jsonify({"error": "title and start_dt are required"}), 400
|
||||
try:
|
||||
start_dt = _parse_dt(data["start_dt"])
|
||||
end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid datetime format"}), 400
|
||||
try:
|
||||
event = await events_svc.create_event(
|
||||
user_id=_get_current_user_id(),
|
||||
title=data["title"],
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
duration_minutes=data.get("duration_minutes"),
|
||||
all_day=data.get("all_day", False),
|
||||
description=data.get("description", ""),
|
||||
location=data.get("location", ""),
|
||||
color=data.get("color", ""),
|
||||
recurrence=data.get("recurrence"),
|
||||
project_id=data.get("project_id"),
|
||||
reminder_minutes=data.get("reminder_minutes"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify(event.to_dict()), 201
|
||||
|
||||
|
||||
@events_bp.get("/<int:event_id>")
|
||||
@login_required
|
||||
async def get_event(event_id: int):
|
||||
event = await events_svc.get_event(
|
||||
user_id=_get_current_user_id(),
|
||||
event_id=event_id,
|
||||
)
|
||||
if event is None:
|
||||
return jsonify({"error": "Event not found"}), 404
|
||||
return jsonify(event.to_dict())
|
||||
|
||||
|
||||
@events_bp.patch("/<int:event_id>")
|
||||
@login_required
|
||||
async def update_event(event_id: int):
|
||||
data = await request.get_json() or {}
|
||||
fields: dict = {}
|
||||
for str_field in ("title", "description", "location", "color", "recurrence"):
|
||||
if str_field in data:
|
||||
fields[str_field] = data[str_field]
|
||||
for bool_field in ("all_day",):
|
||||
if bool_field in data:
|
||||
fields[bool_field] = data[bool_field]
|
||||
for int_field in ("project_id", "reminder_minutes", "duration_minutes"):
|
||||
if int_field in data:
|
||||
fields[int_field] = data[int_field]
|
||||
for dt_field in ("start_dt", "end_dt"):
|
||||
if dt_field in data:
|
||||
if data[dt_field] is None:
|
||||
# Explicit null clears the field (e.g. removing end_dt)
|
||||
fields[dt_field] = None
|
||||
elif data[dt_field]:
|
||||
try:
|
||||
fields[dt_field] = _parse_dt(data[dt_field])
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
|
||||
try:
|
||||
event = await events_svc.update_event(
|
||||
user_id=_get_current_user_id(),
|
||||
event_id=event_id,
|
||||
**fields,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if event is None:
|
||||
return jsonify({"error": "Event not found"}), 404
|
||||
return jsonify(event.to_dict())
|
||||
|
||||
|
||||
@events_bp.delete("/<int:event_id>")
|
||||
@login_required
|
||||
async def delete_event(event_id: int):
|
||||
from scribe.services.trash import delete as trash_delete
|
||||
batch = await trash_delete(_get_current_user_id(), "event", event_id)
|
||||
if batch is None:
|
||||
return jsonify({"error": "Event not found"}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@events_bp.post("/sync")
|
||||
@login_required
|
||||
async def sync_caldav():
|
||||
"""Trigger a CalDAV pull sync for the current user."""
|
||||
from scribe.services.caldav_sync import sync_user_events
|
||||
result = await sync_user_events(user_id=_get_current_user_id())
|
||||
return jsonify(result)
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unified Knowledge endpoint — notes, tasks, plans, and processes in one queryable feed."""
|
||||
"""Unified Knowledge endpoint — notes, people, places, lists in one queryable feed."""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
|
||||
|
||||
_VALID_TYPES = {"note", "task", "plan", "process"}
|
||||
_VALID_TYPES = {"note", "person", "place", "list", "task", "plan", "process"}
|
||||
_VALID_SORTS = {"modified", "created", "alpha", "type"}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ async def list_knowledge():
|
||||
"""Return paginated knowledge objects with optional filtering.
|
||||
|
||||
Query params:
|
||||
type — one of note|task|plan|process (omit for all)
|
||||
type — one of note|person|place|list (omit for all, excludes tasks)
|
||||
tags — comma-separated tag filter (AND logic)
|
||||
sort — modified|created|alpha|type (default: modified)
|
||||
q — search query (semantic when provided, keyword fallback)
|
||||
|
||||
@@ -95,6 +95,7 @@ async def create_note_route():
|
||||
project_id = proj.id
|
||||
|
||||
note_type = data.get("note_type", "note")
|
||||
entity_meta = data.get("metadata") or None
|
||||
|
||||
try:
|
||||
note = await create_note(
|
||||
@@ -110,6 +111,7 @@ async def create_note_route():
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
note_type=note_type,
|
||||
entity_meta=entity_meta,
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
@@ -204,6 +206,9 @@ async def update_note_route(note_id: int):
|
||||
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
||||
if key in data:
|
||||
fields[key] = data[key]
|
||||
if "metadata" in data:
|
||||
fields["entity_meta"] = data["metadata"] or None
|
||||
|
||||
if "due_date" in data:
|
||||
if data["due_date"]:
|
||||
result = parse_iso_date(data["due_date"], "due_date")
|
||||
@@ -243,6 +248,8 @@ async def patch_note_route(note_id: int):
|
||||
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
||||
if key in data:
|
||||
fields[key] = data[key]
|
||||
if "metadata" in data:
|
||||
fields["entity_meta"] = data["metadata"] or None
|
||||
if "due_date" in data:
|
||||
if data["due_date"]:
|
||||
result = parse_iso_date(data["due_date"], "due_date")
|
||||
|
||||
@@ -1,19 +1,47 @@
|
||||
"""User settings + integrations (SearXNG status).
|
||||
"""User settings + integrations (CalDAV, SearXNG status).
|
||||
|
||||
Chat-model picker endpoints (/models), KV-cache priming, and journal-schedule
|
||||
hooks were removed in Phase 8 alongside the chat/journal subsystems.
|
||||
"""
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import login_required, get_current_user_id
|
||||
from scribe.config import Config
|
||||
from scribe.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection
|
||||
from scribe.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_private_url(url: str) -> bool:
|
||||
"""SSRF-blocking helper: returns True for URLs that resolve to private,
|
||||
loopback, or link-local addresses. Inlined here after services/llm.py
|
||||
(the original home) was removed in Phase 8."""
|
||||
try:
|
||||
host = urlparse(url).hostname
|
||||
if not host:
|
||||
return True
|
||||
# Resolve to all addresses; reject if any is private/loopback/link-local.
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
for family, *_rest, sockaddr in infos:
|
||||
ip_str = sockaddr[0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
except ValueError:
|
||||
continue
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
return True
|
||||
except Exception:
|
||||
# Conservative: if we can't resolve, treat as private (reject).
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
|
||||
|
||||
|
||||
@@ -48,6 +76,52 @@ async def update_settings_route():
|
||||
return jsonify(settings)
|
||||
|
||||
|
||||
@settings_bp.route("/caldav", methods=["GET"])
|
||||
@login_required
|
||||
async def get_caldav():
|
||||
uid = get_current_user_id()
|
||||
config = await get_caldav_config(uid)
|
||||
if config.get("caldav_password"):
|
||||
config["caldav_password"] = "********"
|
||||
return jsonify(config)
|
||||
|
||||
|
||||
@settings_bp.route("/caldav", methods=["PUT"])
|
||||
@login_required
|
||||
async def update_caldav():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
|
||||
# Validate CalDAV URL before saving — block internal/private addresses
|
||||
if "caldav_url" in data:
|
||||
url = str(data.get("caldav_url") or "").strip()
|
||||
if url:
|
||||
parsed_scheme = url.split("://")[0].lower() if "://" in url else ""
|
||||
if parsed_scheme not in ("http", "https"):
|
||||
return jsonify({"error": "CalDAV URL must use http or https"}), 400
|
||||
if _is_private_url(url):
|
||||
return jsonify({"error": "CalDAV URL must not point to an internal or private address"}), 400
|
||||
|
||||
settings_to_save = {}
|
||||
for key in CALDAV_SETTING_KEYS:
|
||||
if key in data:
|
||||
if key == "caldav_password" and data[key] == "********":
|
||||
continue
|
||||
settings_to_save[key] = str(data[key])
|
||||
|
||||
if settings_to_save:
|
||||
await set_settings_batch(uid, settings_to_save)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@settings_bp.route("/caldav/test", methods=["POST"])
|
||||
@login_required
|
||||
async def test_caldav():
|
||||
uid = get_current_user_id()
|
||||
result = await test_connection(uid)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@settings_bp.route("/search", methods=["GET"])
|
||||
@login_required
|
||||
async def test_search():
|
||||
|
||||
@@ -4,6 +4,7 @@ from datetime import date, datetime, timezone
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.event import Event
|
||||
from scribe.models.milestone import Milestone
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.note_draft import NoteDraft
|
||||
@@ -24,10 +25,9 @@ from scribe.models.user import User
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Backup format version. v3 (2026-06) added rulebooks/topics/rules + their
|
||||
# project subscription/suppression join tables. v4 (2026-07) dropped events
|
||||
# when the calendar surface was retired — old v3 events are skipped on restore.
|
||||
# Bump when the serialized schema changes.
|
||||
BACKUP_VERSION = 4
|
||||
# project subscription/suppression join tables, and events — all silently
|
||||
# dropped by v2. Bump when the serialized schema changes.
|
||||
BACKUP_VERSION = 3
|
||||
|
||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||
# explicit rather than silent. ACL (groups/shares) is a coherent follow-up;
|
||||
@@ -80,6 +80,7 @@ async def export_full_backup() -> dict:
|
||||
rulebooks = (await session.execute(select(Rulebook))).scalars().all()
|
||||
topics = (await session.execute(select(RulebookTopic))).scalars().all()
|
||||
rules = (await session.execute(select(Rule))).scalars().all()
|
||||
events = (await session.execute(select(Event))).scalars().all()
|
||||
subscriptions = (await session.execute(
|
||||
select(project_rulebook_subscriptions)
|
||||
)).all()
|
||||
@@ -244,6 +245,27 @@ async def export_full_backup() -> dict:
|
||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||
"events": [
|
||||
{
|
||||
"id": e.id,
|
||||
"user_id": e.user_id,
|
||||
"project_id": e.project_id,
|
||||
"uid": e.uid,
|
||||
"caldav_uid": e.caldav_uid,
|
||||
"title": e.title,
|
||||
"start_dt": e.start_dt.isoformat() if e.start_dt else None,
|
||||
"duration_minutes": e.duration_minutes,
|
||||
"all_day": e.all_day,
|
||||
"description": e.description,
|
||||
"location": e.location,
|
||||
"color": e.color,
|
||||
"recurrence": e.recurrence,
|
||||
"reminder_minutes": e.reminder_minutes,
|
||||
"created_at": e.created_at.isoformat(),
|
||||
"updated_at": e.updated_at.isoformat(),
|
||||
}
|
||||
for e in events
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -292,6 +314,9 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
rules = (await session.execute(
|
||||
select(Rule).where(or_(*rule_filters))
|
||||
)).scalars().all() if rule_filters else []
|
||||
events = (await session.execute(
|
||||
select(Event).where(Event.user_id == user_id)
|
||||
)).scalars().all()
|
||||
if project_ids:
|
||||
subscriptions = (await session.execute(
|
||||
select(project_rulebook_subscriptions).where(
|
||||
@@ -455,6 +480,27 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||
"events": [
|
||||
{
|
||||
"id": e.id,
|
||||
"user_id": e.user_id,
|
||||
"project_id": e.project_id,
|
||||
"uid": e.uid,
|
||||
"caldav_uid": e.caldav_uid,
|
||||
"title": e.title,
|
||||
"start_dt": e.start_dt.isoformat() if e.start_dt else None,
|
||||
"duration_minutes": e.duration_minutes,
|
||||
"all_day": e.all_day,
|
||||
"description": e.description,
|
||||
"location": e.location,
|
||||
"color": e.color,
|
||||
"recurrence": e.recurrence,
|
||||
"reminder_minutes": e.reminder_minutes,
|
||||
"created_at": e.created_at.isoformat(),
|
||||
"updated_at": e.updated_at.isoformat(),
|
||||
}
|
||||
for e in events
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -545,17 +591,17 @@ async def _restore_v1(data: dict) -> dict:
|
||||
async def _restore_v2(data: dict) -> dict:
|
||||
"""Restore v2/v3 backup with full FK re-mapping.
|
||||
|
||||
Conversations, push subscriptions, and (as of v4) events in older backups
|
||||
are silently skipped — those subsystems were removed. v3+ sections
|
||||
(rulebooks/topics/rules/join-tables) are guarded by data.get so a v2
|
||||
payload restores without them.
|
||||
Conversations + push subscriptions in pre-pivot backups are silently
|
||||
skipped — those subsystems were removed in the MCP-first pivot. v3-only
|
||||
sections (rulebooks/topics/rules/join-tables/events) are guarded by
|
||||
data.get so a v2 payload restores without them.
|
||||
"""
|
||||
stats: dict[str, int] = {
|
||||
"users": 0, "projects": 0, "milestones": 0, "notes": 0,
|
||||
"task_logs": 0, "note_drafts": 0, "note_versions": 0,
|
||||
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
|
||||
"rulebook_subscriptions": 0, "rule_suppressions": 0,
|
||||
"topic_suppressions": 0,
|
||||
"topic_suppressions": 0, "events": 0,
|
||||
}
|
||||
|
||||
async with async_session() as session:
|
||||
@@ -814,6 +860,31 @@ async def _restore_v2(data: dict) -> dict:
|
||||
))
|
||||
stats["topic_suppressions"] += 1
|
||||
|
||||
# 15. Events (v3)
|
||||
for e_data in data.get("events", []):
|
||||
mapped_uid = user_id_map.get(e_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
ev = Event(
|
||||
user_id=mapped_uid,
|
||||
project_id=project_id_map.get(e_data["project_id"]) if e_data.get("project_id") else None,
|
||||
uid=e_data.get("uid", ""),
|
||||
caldav_uid=e_data.get("caldav_uid", ""),
|
||||
title=e_data.get("title", ""),
|
||||
start_dt=_dt(e_data.get("start_dt")),
|
||||
duration_minutes=e_data.get("duration_minutes"),
|
||||
all_day=e_data.get("all_day", False),
|
||||
description=e_data.get("description", ""),
|
||||
location=e_data.get("location", ""),
|
||||
color=e_data.get("color", ""),
|
||||
recurrence=e_data.get("recurrence"),
|
||||
reminder_minutes=e_data.get("reminder_minutes"),
|
||||
created_at=_dt(e_data.get("created_at")),
|
||||
updated_at=_dt(e_data.get("updated_at")),
|
||||
)
|
||||
session.add(ev)
|
||||
stats["events"] += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info("Restored v2/v3 backup: %s", stats)
|
||||
|
||||
@@ -0,0 +1,770 @@
|
||||
"""CalDAV calendar integration service."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date as date_type, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import caldav
|
||||
import icalendar
|
||||
|
||||
from scribe.services.settings import get_all_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name", "caldav_timezone"]
|
||||
|
||||
# Sentinel: distinguishes "leave the RRULE untouched" from "clear it" (None/"")
|
||||
# in update_event, since None is a meaningful value for recurrence.
|
||||
_RECURRENCE_UNSET = object()
|
||||
|
||||
|
||||
async def get_caldav_config(user_id: int) -> dict[str, str]:
|
||||
"""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 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"))
|
||||
|
||||
|
||||
def _get_calendar(client: caldav.DAVClient, calendar_name: str) -> caldav.Calendar:
|
||||
"""Get a named calendar or the first available one (synchronous)."""
|
||||
principal = client.principal()
|
||||
calendars = principal.calendars()
|
||||
if not calendars:
|
||||
raise ValueError("No calendars found on the CalDAV server.")
|
||||
if calendar_name:
|
||||
for cal in calendars:
|
||||
if cal.name == calendar_name:
|
||||
return cal
|
||||
names = [c.name for c in calendars]
|
||||
raise ValueError(f"Calendar '{calendar_name}' not found. Available: {', '.join(names)}")
|
||||
return calendars[0]
|
||||
|
||||
|
||||
def _get_all_calendars(client: caldav.DAVClient) -> list[caldav.Calendar]:
|
||||
"""Get all calendars for the user (synchronous)."""
|
||||
principal = client.principal()
|
||||
calendars = principal.calendars()
|
||||
if not calendars:
|
||||
raise ValueError("No calendars found on the CalDAV server.")
|
||||
return calendars
|
||||
|
||||
|
||||
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.get("caldav_username") or None,
|
||||
password=config.get("caldav_password") or None,
|
||||
)
|
||||
|
||||
|
||||
def _parse_vevent(component) -> dict | None:
|
||||
"""Extract event data from a VEVENT component."""
|
||||
if component.name != "VEVENT":
|
||||
return None
|
||||
title = str(component.get("SUMMARY", ""))
|
||||
dtstart = component.get("DTSTART")
|
||||
dtend = component.get("DTEND")
|
||||
location = str(component.get("LOCATION", ""))
|
||||
description = str(component.get("DESCRIPTION", ""))
|
||||
uid = str(component.get("UID", ""))
|
||||
start_str = dtstart.dt.isoformat() if dtstart else ""
|
||||
end_str = dtend.dt.isoformat() if dtend else ""
|
||||
|
||||
result = {
|
||||
"uid": uid,
|
||||
"title": title,
|
||||
"start": start_str,
|
||||
"end": end_str,
|
||||
"location": location,
|
||||
"description": description,
|
||||
}
|
||||
|
||||
# Extract recurrence rule
|
||||
rrule = component.get("RRULE")
|
||||
if rrule:
|
||||
result["recurrence"] = rrule.to_ical().decode("utf-8")
|
||||
|
||||
# Extract alarms
|
||||
alarms = []
|
||||
for sub in component.subcomponents:
|
||||
if sub.name == "VALARM":
|
||||
trigger = sub.get("TRIGGER")
|
||||
if trigger and trigger.dt:
|
||||
minutes = abs(int(trigger.dt.total_seconds() // 60))
|
||||
alarms.append({"minutes_before": minutes})
|
||||
if alarms:
|
||||
result["alarms"] = alarms
|
||||
|
||||
# Extract attendees
|
||||
attendees = component.get("ATTENDEE")
|
||||
if attendees:
|
||||
if not isinstance(attendees, list):
|
||||
attendees = [attendees]
|
||||
result["attendees"] = [str(a).replace("mailto:", "") for a in attendees]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _parse_vtodo(component) -> dict | None:
|
||||
"""Extract todo data from a VTODO component."""
|
||||
if component.name != "VTODO":
|
||||
return None
|
||||
uid = str(component.get("UID", ""))
|
||||
summary = str(component.get("SUMMARY", ""))
|
||||
description = str(component.get("DESCRIPTION", ""))
|
||||
status = str(component.get("STATUS", ""))
|
||||
due = component.get("DUE")
|
||||
due_str = due.dt.isoformat() if due else ""
|
||||
priority = component.get("PRIORITY")
|
||||
priority_val = int(priority) if priority else None
|
||||
|
||||
return {
|
||||
"uid": uid,
|
||||
"summary": summary,
|
||||
"description": description,
|
||||
"due": due_str,
|
||||
"status": status,
|
||||
"priority": priority_val,
|
||||
}
|
||||
|
||||
|
||||
def _apply_timezone(dt: datetime, timezone: str | None) -> datetime:
|
||||
"""Apply a timezone to a naive datetime. Returns dt unchanged if already aware."""
|
||||
if dt.tzinfo is not None:
|
||||
return dt
|
||||
if timezone:
|
||||
return dt.replace(tzinfo=ZoneInfo(timezone))
|
||||
return dt
|
||||
|
||||
|
||||
def _build_valarm(minutes_before: int) -> icalendar.Alarm:
|
||||
"""Create a DISPLAY alarm component triggered N minutes before the event."""
|
||||
alarm = icalendar.Alarm()
|
||||
alarm.add("action", "DISPLAY")
|
||||
alarm.add("description", "Reminder")
|
||||
alarm.add("trigger", timedelta(minutes=-minutes_before))
|
||||
return alarm
|
||||
|
||||
|
||||
def _add_attendees(event: icalendar.Event, attendees: list[str]) -> None:
|
||||
"""Add mailto: attendees to an iCalendar event."""
|
||||
for email in attendees:
|
||||
attendee = icalendar.vCalAddress(f"mailto:{email}")
|
||||
event.add("attendee", attendee)
|
||||
|
||||
|
||||
def _check_config(config: dict[str, str]) -> None:
|
||||
"""Raise if CalDAV is not configured."""
|
||||
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(
|
||||
user_id: int,
|
||||
title: str,
|
||||
start: str,
|
||||
end: str | None = None,
|
||||
duration: int | None = None,
|
||||
description: str | None = None,
|
||||
location: str | None = None,
|
||||
all_day: bool = False,
|
||||
recurrence: str | None = None,
|
||||
timezone: str | None = None,
|
||||
reminder_minutes: int | None = None,
|
||||
attendees: list[str] | None = None,
|
||||
calendar_name: str | None = None,
|
||||
uid: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a calendar event.
|
||||
|
||||
start/end are ISO date (YYYY-MM-DD) or datetime strings.
|
||||
If all_day is True, DTSTART/DTEND use DATE values.
|
||||
recurrence is an iCalendar RRULE string (e.g. "FREQ=YEARLY").
|
||||
"""
|
||||
config = await get_caldav_config(user_id)
|
||||
_check_config(config)
|
||||
|
||||
tz = timezone or config.get("caldav_timezone") or None
|
||||
|
||||
cal = icalendar.Calendar()
|
||||
cal.add("prodid", "-//Scribe//EN")
|
||||
cal.add("version", "2.0")
|
||||
|
||||
event = icalendar.Event()
|
||||
if uid:
|
||||
# Remove auto-generated UID if the library added one, then inject ours
|
||||
if "UID" in event:
|
||||
del event["UID"]
|
||||
event.add("uid", uid)
|
||||
event.add("summary", title)
|
||||
|
||||
if all_day:
|
||||
# All-day events use DATE values (no time component)
|
||||
d_start = datetime.fromisoformat(start).date() if "T" in start else date_type.fromisoformat(start)
|
||||
if end:
|
||||
d_end = datetime.fromisoformat(end).date() if "T" in end else date_type.fromisoformat(end)
|
||||
else:
|
||||
d_end = d_start + timedelta(days=1)
|
||||
event.add("dtstart", d_start)
|
||||
event.add("dtend", d_end)
|
||||
result_start = d_start.isoformat()
|
||||
result_end = d_end.isoformat()
|
||||
else:
|
||||
dt_start = _apply_timezone(datetime.fromisoformat(start), tz)
|
||||
event.add("dtstart", dt_start)
|
||||
result_start = dt_start.isoformat()
|
||||
if end:
|
||||
dt_end = _apply_timezone(datetime.fromisoformat(end), tz)
|
||||
elif duration:
|
||||
dt_end = dt_start + timedelta(minutes=duration)
|
||||
else:
|
||||
dt_end = None
|
||||
if dt_end is not None:
|
||||
event.add("dtend", dt_end)
|
||||
result_end = dt_end.isoformat()
|
||||
else:
|
||||
# Point event (no end, no duration): emit DTSTART only. Fabricating
|
||||
# a 60-min DTEND here would round-trip back on the next pull as
|
||||
# duration_minutes=60, silently lengthening a point event.
|
||||
result_end = None
|
||||
|
||||
if description:
|
||||
event.add("description", description)
|
||||
if location:
|
||||
event.add("location", location)
|
||||
if recurrence:
|
||||
# Parse RRULE string like "FREQ=YEARLY" into a vRecur dict
|
||||
rrule_parts = {}
|
||||
for part in recurrence.split(";"):
|
||||
if "=" in part:
|
||||
key, value = part.split("=", 1)
|
||||
rrule_parts[key.strip().lower()] = value.strip()
|
||||
event.add("rrule", rrule_parts)
|
||||
if reminder_minutes is not None:
|
||||
event.add_component(_build_valarm(reminder_minutes))
|
||||
if attendees:
|
||||
_add_attendees(event, attendees)
|
||||
|
||||
cal.add_component(event)
|
||||
|
||||
ical_str = cal.to_ical().decode("utf-8")
|
||||
|
||||
def _save():
|
||||
client = _make_client(config)
|
||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
||||
calendar = _get_calendar(client, cal_name)
|
||||
calendar.save_event(ical_str)
|
||||
|
||||
await asyncio.to_thread(_save)
|
||||
|
||||
result = {
|
||||
"title": title,
|
||||
"start": result_start,
|
||||
"end": result_end,
|
||||
"all_day": all_day,
|
||||
}
|
||||
if recurrence:
|
||||
result["recurrence"] = recurrence
|
||||
return result
|
||||
|
||||
|
||||
async def list_events(user_id: int, date_from: str, date_to: str) -> list[dict]:
|
||||
"""List calendar events in a date range. Dates are ISO datetime strings.
|
||||
|
||||
Searches all calendars unless caldav_calendar_name is configured.
|
||||
"""
|
||||
config = await get_caldav_config(user_id)
|
||||
_check_config(config)
|
||||
|
||||
dt_from = datetime.fromisoformat(date_from)
|
||||
dt_to = datetime.fromisoformat(date_to)
|
||||
|
||||
def _search():
|
||||
client = _make_client(config)
|
||||
cal_name = config.get("caldav_calendar_name", "")
|
||||
if cal_name:
|
||||
calendars = [_get_calendar(client, cal_name)]
|
||||
else:
|
||||
calendars = _get_all_calendars(client)
|
||||
all_results = []
|
||||
for calendar in calendars:
|
||||
try:
|
||||
all_results.extend(calendar.date_search(dt_from, dt_to))
|
||||
except Exception:
|
||||
logger.warning("Failed to search calendar '%s'", getattr(calendar, 'name', '?'))
|
||||
return all_results
|
||||
|
||||
results = await asyncio.to_thread(_search)
|
||||
|
||||
events = []
|
||||
for result in results:
|
||||
cal = icalendar.Calendar.from_ical(result.data)
|
||||
for component in cal.walk():
|
||||
parsed = _parse_vevent(component)
|
||||
if parsed:
|
||||
events.append(parsed)
|
||||
return events
|
||||
|
||||
|
||||
async def search_events(user_id: int, query: str, days_ahead: int = 90) -> list[dict]:
|
||||
"""Search events by keyword in the next N days."""
|
||||
now = datetime.now()
|
||||
date_from = now.isoformat()
|
||||
date_to = (now + timedelta(days=days_ahead)).isoformat()
|
||||
|
||||
all_events = await list_events(user_id, date_from, date_to)
|
||||
q = query.lower()
|
||||
return [
|
||||
e for e in all_events
|
||||
if q in e["title"].lower() or q in e.get("location", "").lower() or q in e.get("description", "").lower()
|
||||
]
|
||||
|
||||
|
||||
async def update_event(
|
||||
user_id: int,
|
||||
query: str,
|
||||
title: str | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
description: str | None = None,
|
||||
location: str | None = None,
|
||||
timezone: str | None = None,
|
||||
calendar_name: str | None = None,
|
||||
recurrence: str | None | object = _RECURRENCE_UNSET,
|
||||
) -> dict:
|
||||
"""Update a calendar event matching the query.
|
||||
|
||||
``recurrence``: leave at the sentinel to keep the existing RRULE; pass an
|
||||
RRULE string to set it, or None/"" to remove it. The push path passes the
|
||||
local event's recurrence so RRULE edits propagate to the server.
|
||||
"""
|
||||
config = await get_caldav_config(user_id)
|
||||
_check_config(config)
|
||||
tz = timezone or config.get("caldav_timezone") or None
|
||||
|
||||
def _do_update():
|
||||
client = _make_client(config)
|
||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
||||
now = datetime.now()
|
||||
if cal_name:
|
||||
calendars = [_get_calendar(client, cal_name)]
|
||||
else:
|
||||
calendars = _get_all_calendars(client)
|
||||
results = []
|
||||
for cal in calendars:
|
||||
try:
|
||||
results.extend(cal.date_search(now - timedelta(days=30), now + timedelta(days=365)))
|
||||
except Exception:
|
||||
logger.warning("Failed to search calendar '%s'", getattr(cal, 'name', '?'))
|
||||
|
||||
q = query.lower()
|
||||
matches = []
|
||||
for r in results:
|
||||
cal_obj = icalendar.Calendar.from_ical(r.data)
|
||||
for component in cal_obj.walk():
|
||||
if component.name == "VEVENT":
|
||||
event_title = str(component.get("SUMMARY", ""))
|
||||
if q in event_title.lower():
|
||||
matches.append((r, component))
|
||||
|
||||
if not matches:
|
||||
raise ValueError(f"No event found matching '{query}'.")
|
||||
if len(matches) > 3:
|
||||
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
|
||||
raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
|
||||
|
||||
event_obj, component = matches[0]
|
||||
if title:
|
||||
component["SUMMARY"] = title
|
||||
if start:
|
||||
dt_start = _apply_timezone(datetime.fromisoformat(start), tz)
|
||||
del component["DTSTART"]
|
||||
component.add("dtstart", dt_start)
|
||||
if end:
|
||||
dt_end = _apply_timezone(datetime.fromisoformat(end), tz)
|
||||
if "DTEND" in component:
|
||||
del component["DTEND"]
|
||||
component.add("dtend", dt_end)
|
||||
if description is not None:
|
||||
if "DESCRIPTION" in component:
|
||||
del component["DESCRIPTION"]
|
||||
component.add("description", description)
|
||||
if location is not None:
|
||||
if "LOCATION" in component:
|
||||
del component["LOCATION"]
|
||||
component.add("location", location)
|
||||
if recurrence is not _RECURRENCE_UNSET:
|
||||
# Authoritatively sync the RRULE to the local event: drop the old
|
||||
# rule, then re-add if a non-empty rule was provided (else clear it).
|
||||
if "RRULE" in component:
|
||||
del component["RRULE"]
|
||||
if recurrence:
|
||||
rrule_parts = {}
|
||||
for part in str(recurrence).split(";"):
|
||||
if "=" in part:
|
||||
key, value = part.split("=", 1)
|
||||
rrule_parts[key.strip().lower()] = value.strip()
|
||||
component.add("rrule", rrule_parts)
|
||||
|
||||
# Rebuild ical data and save
|
||||
cal_data = icalendar.Calendar()
|
||||
cal_data.add("prodid", "-//Scribe//EN")
|
||||
cal_data.add("version", "2.0")
|
||||
cal_data.add_component(component)
|
||||
event_obj.data = cal_data.to_ical().decode("utf-8")
|
||||
event_obj.save()
|
||||
|
||||
return _parse_vevent(component)
|
||||
|
||||
return await asyncio.to_thread(_do_update)
|
||||
|
||||
|
||||
async def delete_event(
|
||||
user_id: int,
|
||||
query: str,
|
||||
calendar_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Delete a calendar event matching the query."""
|
||||
config = await get_caldav_config(user_id)
|
||||
_check_config(config)
|
||||
|
||||
def _do_delete():
|
||||
client = _make_client(config)
|
||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
||||
now = datetime.now()
|
||||
if cal_name:
|
||||
calendars = [_get_calendar(client, cal_name)]
|
||||
else:
|
||||
calendars = _get_all_calendars(client)
|
||||
results = []
|
||||
for cal in calendars:
|
||||
try:
|
||||
results.extend(cal.date_search(now - timedelta(days=30), now + timedelta(days=365)))
|
||||
except Exception:
|
||||
logger.warning("Failed to search calendar '%s'", getattr(cal, 'name', '?'))
|
||||
|
||||
q = query.lower()
|
||||
matches = []
|
||||
for r in results:
|
||||
cal_obj = icalendar.Calendar.from_ical(r.data)
|
||||
for component in cal_obj.walk():
|
||||
if component.name == "VEVENT":
|
||||
event_title = str(component.get("SUMMARY", ""))
|
||||
if q in event_title.lower():
|
||||
matches.append((r, component))
|
||||
|
||||
if not matches:
|
||||
raise ValueError(f"No event found matching '{query}'.")
|
||||
if len(matches) > 3:
|
||||
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
|
||||
raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
|
||||
|
||||
event_obj, component = matches[0]
|
||||
parsed = _parse_vevent(component)
|
||||
event_obj.delete()
|
||||
return parsed
|
||||
|
||||
return await asyncio.to_thread(_do_delete)
|
||||
|
||||
|
||||
async def list_calendars(user_id: int) -> list[dict]:
|
||||
"""List all calendars for the user."""
|
||||
config = await get_caldav_config(user_id)
|
||||
_check_config(config)
|
||||
|
||||
def _list():
|
||||
client = _make_client(config)
|
||||
principal = client.principal()
|
||||
calendars = principal.calendars()
|
||||
return [{"name": c.name, "url": str(c.url)} for c in calendars]
|
||||
|
||||
return await asyncio.to_thread(_list)
|
||||
|
||||
|
||||
async def create_todo(
|
||||
user_id: int,
|
||||
summary: str,
|
||||
due: str | None = None,
|
||||
description: str | None = None,
|
||||
priority: int | None = None,
|
||||
reminder_minutes: int | None = None,
|
||||
timezone: str | None = None,
|
||||
calendar_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a CalDAV todo (VTODO)."""
|
||||
config = await get_caldav_config(user_id)
|
||||
_check_config(config)
|
||||
tz = timezone or config.get("caldav_timezone") or None
|
||||
|
||||
def _create():
|
||||
client = _make_client(config)
|
||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
||||
calendar = _get_calendar(client, cal_name)
|
||||
|
||||
kwargs = {"summary": summary}
|
||||
if due:
|
||||
dt_due = datetime.fromisoformat(due)
|
||||
dt_due = _apply_timezone(dt_due, tz)
|
||||
kwargs["due"] = dt_due
|
||||
|
||||
todo = calendar.save_todo(**kwargs)
|
||||
|
||||
# Modify component for extra fields
|
||||
cal_obj = icalendar.Calendar.from_ical(todo.data)
|
||||
modified = False
|
||||
for component in cal_obj.walk():
|
||||
if component.name == "VTODO":
|
||||
if description:
|
||||
component.add("description", description)
|
||||
modified = True
|
||||
if priority is not None:
|
||||
component.add("priority", priority)
|
||||
modified = True
|
||||
if reminder_minutes is not None:
|
||||
component.add_component(_build_valarm(reminder_minutes))
|
||||
modified = True
|
||||
if modified:
|
||||
todo.data = cal_obj.to_ical().decode("utf-8")
|
||||
todo.save()
|
||||
return _parse_vtodo(component)
|
||||
|
||||
return {"summary": summary}
|
||||
|
||||
return await asyncio.to_thread(_create)
|
||||
|
||||
|
||||
async def list_todos(
|
||||
user_id: int,
|
||||
include_completed: bool = False,
|
||||
calendar_name: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""List CalDAV todos."""
|
||||
config = await get_caldav_config(user_id)
|
||||
_check_config(config)
|
||||
|
||||
def _list():
|
||||
client = _make_client(config)
|
||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
||||
calendar = _get_calendar(client, cal_name)
|
||||
todos = calendar.todos(include_completed=include_completed)
|
||||
results = []
|
||||
for t in todos:
|
||||
cal_obj = icalendar.Calendar.from_ical(t.data)
|
||||
for component in cal_obj.walk():
|
||||
parsed = _parse_vtodo(component)
|
||||
if parsed:
|
||||
results.append(parsed)
|
||||
return results
|
||||
|
||||
return await asyncio.to_thread(_list)
|
||||
|
||||
|
||||
async def search_todos(
|
||||
user_id: int,
|
||||
query: str,
|
||||
include_completed: bool = False,
|
||||
calendar_name: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Search CalDAV todos by keyword in summary or description."""
|
||||
todos = await list_todos(user_id, include_completed=include_completed, calendar_name=calendar_name)
|
||||
q = query.lower()
|
||||
return [
|
||||
t for t in todos
|
||||
if q in t.get("summary", "").lower() or q in (t.get("description") or "").lower()
|
||||
]
|
||||
|
||||
|
||||
async def complete_todo(
|
||||
user_id: int,
|
||||
query: str,
|
||||
calendar_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Complete a CalDAV todo matching the query."""
|
||||
config = await get_caldav_config(user_id)
|
||||
_check_config(config)
|
||||
|
||||
def _complete():
|
||||
client = _make_client(config)
|
||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
||||
calendar = _get_calendar(client, cal_name)
|
||||
todos = calendar.todos(include_completed=False)
|
||||
|
||||
q = query.lower()
|
||||
matches = []
|
||||
for t in todos:
|
||||
cal_obj = icalendar.Calendar.from_ical(t.data)
|
||||
for component in cal_obj.walk():
|
||||
if component.name == "VTODO":
|
||||
s = str(component.get("SUMMARY", ""))
|
||||
if q in s.lower():
|
||||
matches.append((t, component))
|
||||
|
||||
if not matches:
|
||||
raise ValueError(f"No todo found matching '{query}'.")
|
||||
if len(matches) > 3:
|
||||
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
|
||||
raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
|
||||
|
||||
todo_obj, component = matches[0]
|
||||
todo_obj.complete()
|
||||
|
||||
# Re-parse after completing
|
||||
cal_obj = icalendar.Calendar.from_ical(todo_obj.data)
|
||||
for comp in cal_obj.walk():
|
||||
parsed = _parse_vtodo(comp)
|
||||
if parsed:
|
||||
return parsed
|
||||
return {"summary": str(component.get("SUMMARY", "")), "status": "COMPLETED"}
|
||||
|
||||
return await asyncio.to_thread(_complete)
|
||||
|
||||
|
||||
async def update_todo(
|
||||
user_id: int,
|
||||
query: str,
|
||||
summary: str | None = None,
|
||||
due: str | None = None,
|
||||
description: str | None = None,
|
||||
priority: int | None = None,
|
||||
timezone: str | None = None,
|
||||
calendar_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Update a CalDAV todo matching the query."""
|
||||
config = await get_caldav_config(user_id)
|
||||
_check_config(config)
|
||||
tz = timezone or config.get("caldav_timezone") or None
|
||||
|
||||
def _do_update():
|
||||
client = _make_client(config)
|
||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
||||
calendar = _get_calendar(client, cal_name)
|
||||
todos = calendar.todos(include_completed=True)
|
||||
|
||||
q = query.lower()
|
||||
matches = []
|
||||
for t in todos:
|
||||
cal_obj = icalendar.Calendar.from_ical(t.data)
|
||||
for component in cal_obj.walk():
|
||||
if component.name == "VTODO":
|
||||
s = str(component.get("SUMMARY", ""))
|
||||
if q in s.lower():
|
||||
matches.append((t, component))
|
||||
|
||||
if not matches:
|
||||
raise ValueError(f"No todo found matching '{query}'.")
|
||||
if len(matches) > 3:
|
||||
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
|
||||
raise ValueError(
|
||||
f"Too many matches ({len(matches)}) for '{query}'. "
|
||||
f"Be more specific. Found: {', '.join(titles[:10])}"
|
||||
)
|
||||
|
||||
todo_obj, component = matches[0]
|
||||
|
||||
if summary:
|
||||
component["SUMMARY"] = summary
|
||||
if description is not None:
|
||||
if "DESCRIPTION" in component:
|
||||
del component["DESCRIPTION"]
|
||||
component.add("description", description)
|
||||
if priority is not None:
|
||||
if "PRIORITY" in component:
|
||||
del component["PRIORITY"]
|
||||
component.add("priority", priority)
|
||||
if due:
|
||||
if "DUE" in component:
|
||||
del component["DUE"]
|
||||
try:
|
||||
dt = datetime.fromisoformat(due)
|
||||
dt = _apply_timezone(dt, tz)
|
||||
component.add("due", dt)
|
||||
except ValueError:
|
||||
component.add("due", date_type.fromisoformat(due))
|
||||
|
||||
# Rebuild ical data and save
|
||||
cal_data = icalendar.Calendar()
|
||||
cal_data.add("prodid", "-//Scribe//EN")
|
||||
cal_data.add("version", "2.0")
|
||||
cal_data.add_component(component)
|
||||
todo_obj.data = cal_data.to_ical().decode("utf-8")
|
||||
todo_obj.save()
|
||||
|
||||
return _parse_vtodo(component)
|
||||
|
||||
return await asyncio.to_thread(_do_update)
|
||||
|
||||
|
||||
async def delete_todo(
|
||||
user_id: int,
|
||||
query: str,
|
||||
calendar_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Delete a CalDAV todo matching the query."""
|
||||
config = await get_caldav_config(user_id)
|
||||
_check_config(config)
|
||||
|
||||
def _delete():
|
||||
client = _make_client(config)
|
||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
||||
calendar = _get_calendar(client, cal_name)
|
||||
todos = calendar.todos(include_completed=True)
|
||||
|
||||
q = query.lower()
|
||||
matches = []
|
||||
for t in todos:
|
||||
cal_obj = icalendar.Calendar.from_ical(t.data)
|
||||
for component in cal_obj.walk():
|
||||
if component.name == "VTODO":
|
||||
s = str(component.get("SUMMARY", ""))
|
||||
if q in s.lower():
|
||||
matches.append((t, component))
|
||||
|
||||
if not matches:
|
||||
raise ValueError(f"No todo found matching '{query}'.")
|
||||
if len(matches) > 3:
|
||||
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
|
||||
raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
|
||||
|
||||
todo_obj, component = matches[0]
|
||||
parsed = _parse_vtodo(component)
|
||||
todo_obj.delete()
|
||||
return parsed
|
||||
|
||||
return await asyncio.to_thread(_delete)
|
||||
|
||||
|
||||
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"):
|
||||
return {"success": False, "error": "CalDAV is not configured."}
|
||||
|
||||
def _test():
|
||||
client = _make_client(config)
|
||||
principal = client.principal()
|
||||
calendars = principal.calendars()
|
||||
return [c.name for c in calendars]
|
||||
|
||||
try:
|
||||
calendar_names = await asyncio.to_thread(_test)
|
||||
return {
|
||||
"success": True,
|
||||
"calendars": calendar_names,
|
||||
"message": f"Connected successfully. Found {len(calendar_names)} calendar(s).",
|
||||
}
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "401" in error_msg or "403" in error_msg or "Unauthorized" in error_msg:
|
||||
error_msg = "Authentication failed. Check your username and password."
|
||||
elif "404" in error_msg or "Not Found" in error_msg:
|
||||
error_msg = "CalDAV endpoint not found. Check your URL."
|
||||
elif "Connection" in error_msg or "resolve" in error_msg:
|
||||
error_msg = f"Connection failed: {error_msg}"
|
||||
return {"success": False, "error": error_msg}
|
||||
@@ -0,0 +1,247 @@
|
||||
"""CalDAV pull sync — imports remote events into the internal event store.
|
||||
|
||||
Runs as a scheduled job (hourly) and is also callable via the API.
|
||||
Only syncs events in a rolling 30-day-past / 180-day-future window.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.event import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SYNC_PAST_DAYS = 30
|
||||
_SYNC_FUTURE_DAYS = 180
|
||||
# Wall-clock cap on the blocking CalDAV fetch so a hung/slow server can't
|
||||
# wedge the hourly sweep indefinitely.
|
||||
_SYNC_TIMEOUT_SECONDS = 120
|
||||
|
||||
|
||||
def _parse_dt(val: Any) -> datetime | None:
|
||||
"""Convert a date or datetime from an iCal component to a UTC-aware datetime."""
|
||||
if val is None:
|
||||
return None
|
||||
import datetime as _dt_mod
|
||||
if isinstance(val, _dt_mod.datetime):
|
||||
if val.tzinfo is None:
|
||||
return val.replace(tzinfo=timezone.utc)
|
||||
return val.astimezone(timezone.utc)
|
||||
if isinstance(val, _dt_mod.date):
|
||||
# All-day date: treat as midnight UTC
|
||||
return datetime(val.year, val.month, val.day, tzinfo=timezone.utc)
|
||||
return None
|
||||
|
||||
|
||||
def _sync_one_user(config: dict[str, str], user_id: int) -> list[dict]:
|
||||
"""Synchronous CalDAV fetch — runs in a thread executor."""
|
||||
import caldav # noqa: PLC0415
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
range_start = now - timedelta(days=_SYNC_PAST_DAYS)
|
||||
range_end = now + timedelta(days=_SYNC_FUTURE_DAYS)
|
||||
|
||||
client = caldav.DAVClient(
|
||||
url=config["caldav_url"],
|
||||
username=config.get("caldav_username") or None,
|
||||
password=config.get("caldav_password") or None,
|
||||
)
|
||||
principal = client.principal()
|
||||
calendars = principal.calendars()
|
||||
if not calendars:
|
||||
return []
|
||||
|
||||
cal_name = config.get("caldav_calendar_name", "")
|
||||
if cal_name:
|
||||
calendars = [c for c in calendars if c.name == cal_name] or calendars
|
||||
|
||||
events: list[dict] = []
|
||||
for calendar in calendars:
|
||||
try:
|
||||
results = calendar.date_search(start=range_start, end=range_end, expand=False)
|
||||
except Exception:
|
||||
logger.warning("CalDAV date_search failed for calendar %s", getattr(calendar, "name", "?"), exc_info=True)
|
||||
continue
|
||||
for vevent_obj in results:
|
||||
try:
|
||||
ical = vevent_obj.icalendar_instance
|
||||
for component in ical.walk():
|
||||
if component.name != "VEVENT":
|
||||
continue
|
||||
dtstart = component.get("DTSTART")
|
||||
dtend = component.get("DTEND")
|
||||
uid = str(component.get("UID", ""))
|
||||
if not uid:
|
||||
continue
|
||||
start_dt = _parse_dt(dtstart.dt if dtstart else None)
|
||||
end_dt = _parse_dt(dtend.dt if dtend else None)
|
||||
if start_dt is None:
|
||||
continue
|
||||
|
||||
import datetime as _dt_mod
|
||||
all_day = dtstart and isinstance(dtstart.dt, _dt_mod.date) and not isinstance(dtstart.dt, _dt_mod.datetime)
|
||||
|
||||
rrule = component.get("RRULE")
|
||||
recurrence = rrule.to_ical().decode("utf-8") if rrule else None
|
||||
|
||||
events.append({
|
||||
"caldav_uid": uid,
|
||||
"title": str(component.get("SUMMARY", "")),
|
||||
"start_dt": start_dt,
|
||||
"end_dt": end_dt,
|
||||
"all_day": bool(all_day),
|
||||
"description": str(component.get("DESCRIPTION", "")),
|
||||
"location": str(component.get("LOCATION", "")),
|
||||
"recurrence": recurrence,
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("Failed to parse CalDAV event", exc_info=True)
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def sync_user_events(user_id: int) -> dict:
|
||||
"""Pull CalDAV events for one user and upsert into the DB.
|
||||
|
||||
Returns a summary dict: {created, updated, unchanged}.
|
||||
"""
|
||||
from scribe.services.caldav import get_caldav_config, is_caldav_configured # noqa: PLC0415
|
||||
|
||||
if not await is_caldav_configured(user_id):
|
||||
return {"skipped": True, "reason": "CalDAV not configured"}
|
||||
|
||||
config = await get_caldav_config(user_id)
|
||||
|
||||
started = datetime.now(timezone.utc)
|
||||
range_start = started - timedelta(days=_SYNC_PAST_DAYS)
|
||||
range_end = started + timedelta(days=_SYNC_FUTURE_DAYS)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
remote_events: list[dict] = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, _sync_one_user, config, user_id),
|
||||
timeout=_SYNC_TIMEOUT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("CalDAV pull sync timed out for user %d after %ds", user_id, _SYNC_TIMEOUT_SECONDS)
|
||||
return {"error": "CalDAV fetch timed out"}
|
||||
except Exception:
|
||||
logger.warning("CalDAV pull sync failed for user %d", user_id, exc_info=True)
|
||||
return {"error": "CalDAV fetch failed"}
|
||||
|
||||
created = updated = unchanged = skipped = deleted = 0
|
||||
|
||||
async with async_session() as session:
|
||||
for ev in remote_events:
|
||||
caldav_uid = ev["caldav_uid"]
|
||||
# Storage uses duration, not end_dt. Convert here so the
|
||||
# rest of this function can compare/upsert in one shape.
|
||||
ev_start = ev["start_dt"]
|
||||
ev_end = ev["end_dt"]
|
||||
ev_duration = (
|
||||
int((ev_end - ev_start).total_seconds() // 60)
|
||||
if ev_end is not None and ev_start is not None and ev_end > ev_start
|
||||
else None
|
||||
)
|
||||
ev["duration_minutes"] = ev_duration
|
||||
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
Event.caldav_uid == caldav_uid,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is not None and existing.deleted_at is not None:
|
||||
# The user trashed this event locally. Don't resurrect it by
|
||||
# updating, and don't create a duplicate live copy — leave it
|
||||
# in the trash. (Propagating the delete to the remote server is
|
||||
# tracked separately.)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if existing is None:
|
||||
# Create new event
|
||||
new_ev = Event(
|
||||
user_id=user_id,
|
||||
uid=str(uuid.uuid4()),
|
||||
caldav_uid=caldav_uid,
|
||||
title=ev["title"],
|
||||
start_dt=ev_start,
|
||||
duration_minutes=ev_duration,
|
||||
all_day=ev["all_day"],
|
||||
description=ev["description"],
|
||||
location=ev["location"],
|
||||
recurrence=ev["recurrence"],
|
||||
)
|
||||
session.add(new_ev)
|
||||
created += 1
|
||||
else:
|
||||
# Update if anything changed
|
||||
changed = False
|
||||
for field in ("title", "start_dt", "duration_minutes", "all_day", "description", "location", "recurrence"):
|
||||
if getattr(existing, field) != ev[field]:
|
||||
setattr(existing, field, ev[field])
|
||||
changed = True
|
||||
if changed:
|
||||
updated += 1
|
||||
else:
|
||||
unchanged += 1
|
||||
|
||||
# Reconcile deletions: a previously-synced event (has a caldav_uid)
|
||||
# that no longer appears remotely within the synced window is
|
||||
# soft-deleted, so a delete on the remote propagates locally instead
|
||||
# of orphaning forever. Guarded on a non-empty fetch so a spurious
|
||||
# empty result can't wipe every local copy.
|
||||
if remote_events:
|
||||
remote_uids = {e["caldav_uid"] for e in remote_events}
|
||||
orphan_batch = str(uuid.uuid4())
|
||||
orphan_res = await session.execute(
|
||||
update(Event)
|
||||
.where(
|
||||
Event.user_id == user_id,
|
||||
Event.caldav_uid.isnot(None),
|
||||
Event.caldav_uid.notin_(remote_uids),
|
||||
Event.deleted_at.is_(None),
|
||||
Event.start_dt >= range_start,
|
||||
Event.start_dt <= range_end,
|
||||
)
|
||||
.values(deleted_at=datetime.now(timezone.utc), deleted_batch_id=orphan_batch)
|
||||
)
|
||||
deleted = orphan_res.rowcount or 0
|
||||
|
||||
await session.commit()
|
||||
|
||||
elapsed = (datetime.now(timezone.utc) - started).total_seconds()
|
||||
logger.info(
|
||||
"CalDAV sync user %d: %d created, %d updated, %d unchanged, %d skipped (trashed), "
|
||||
"%d deleted (orphaned) in %.1fs",
|
||||
user_id, created, updated, unchanged, skipped, deleted, elapsed,
|
||||
)
|
||||
return {"created": created, "updated": updated, "unchanged": unchanged,
|
||||
"skipped": skipped, "deleted": deleted}
|
||||
|
||||
|
||||
async def sync_all_users() -> None:
|
||||
"""Pull CalDAV events for all users with CalDAV configured."""
|
||||
from sqlalchemy import select as sa_select # noqa: PLC0415
|
||||
|
||||
from scribe.models.user import User # noqa: PLC0415
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(sa_select(User.id))
|
||||
user_ids = [row[0] for row in result.all()]
|
||||
|
||||
for user_id in user_ids:
|
||||
try:
|
||||
await sync_user_events(user_id)
|
||||
except Exception:
|
||||
logger.warning("CalDAV sync failed for user %d", user_id, exc_info=True)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Dashboard aggregation — assembles the /dashboard landing payload.
|
||||
|
||||
One call: most-recently-active projects (each -> active milestones -> open
|
||||
tasks), recently-completed tasks, week stats. Owner-scoped,
|
||||
tasks), recently-completed tasks, upcoming events, week stats. Owner-scoped,
|
||||
trashed rows excluded. Each section is independent — a failure returns its
|
||||
empty value rather than blanking the page.
|
||||
"""
|
||||
@@ -23,7 +23,7 @@ N_PROJECTS = 3 # most-recently-active projects shown
|
||||
TASKS_PER_GROUP = 5 # open-task cap per milestone / no-milestone group
|
||||
RECENT_DONE_LIMIT = 8 # recently-completed tasks shown
|
||||
OPEN_ISSUES_LIMIT = 10 # open issues shown on the dashboard
|
||||
WINDOW_DAYS = 7 # look-back window (done items, week stats)
|
||||
WINDOW_DAYS = 7 # look-back (done) / look-ahead (events) window
|
||||
_OPEN = ["todo", "in_progress"]
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ async def build_dashboard(user_id: int) -> dict:
|
||||
return {
|
||||
"active_projects": await _safe(_active_projects(user_id), []),
|
||||
"recently_completed": await _safe(_recently_completed(user_id), []),
|
||||
"upcoming_events": await _safe(_upcoming_events(user_id), []),
|
||||
"open_issues": await _safe(_open_issues(user_id), []),
|
||||
"week_stats": await _safe(_week_stats(user_id), {}),
|
||||
}
|
||||
@@ -176,6 +177,18 @@ async def _recently_completed(user_id: int) -> list[dict]:
|
||||
"completed_at": n.completed_at.isoformat()} for n, ptitle in rows]
|
||||
|
||||
|
||||
async def _upcoming_events(user_id: int) -> list[dict]:
|
||||
from scribe.services import events as events_svc
|
||||
now = datetime.now(timezone.utc)
|
||||
rows = await events_svc.list_events(user_id, now, now + timedelta(days=WINDOW_DAYS))
|
||||
out = []
|
||||
for e in rows:
|
||||
d = e if isinstance(e, dict) else e.to_dict()
|
||||
out.append({"id": d["id"], "title": d["title"],
|
||||
"start_dt": d.get("start_dt"), "all_day": d.get("all_day", False)})
|
||||
return out
|
||||
|
||||
|
||||
async def _week_stats(user_id: int) -> dict:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS)
|
||||
async with async_session() as session:
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Scheduler jobs for background maintenance tasks.
|
||||
|
||||
- Reminder notifications: checks every 5 minutes for due event reminders and
|
||||
delivers them to the in-app notification feed.
|
||||
- CalDAV pull sync: runs every hour for all users with CalDAV configured.
|
||||
- Recurring-task spawn: every 15 minutes, creates the next occurrence of any
|
||||
recurring task whose spawn time has arrived.
|
||||
|
||||
Uses the BackgroundScheduler pattern shared with the other *_scheduler modules.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from dateutil.rrule import rrulestr
|
||||
from sqlalchemy import and_, or_, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.event import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reminder job
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _fire_reminders() -> None:
|
||||
"""Fire in-app reminders for events whose reminder time has arrived.
|
||||
|
||||
One-shot events fire once (gated on reminder_sent_at IS NULL). Recurring
|
||||
events fire once PER OCCURRENCE: reminder_sent_at stores the start of the
|
||||
occurrence we last reminded about, so each new occurrence re-arms the
|
||||
reminder instead of the whole series firing only once.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
window_end = now + timedelta(minutes=5)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.reminder_minutes.isnot(None),
|
||||
Event.deleted_at.is_(None),
|
||||
or_(
|
||||
# Recurring events are evaluated every sweep against their
|
||||
# next occurrence (the base start_dt is long past).
|
||||
Event.recurrence.isnot(None),
|
||||
# One-shot events: classic gate.
|
||||
and_(Event.reminder_sent_at.is_(None), Event.start_dt > now),
|
||||
),
|
||||
)
|
||||
)
|
||||
candidates = list(result.scalars().all())
|
||||
|
||||
# (event_id, occurrence_start) — occurrence_start is also the dedup marker
|
||||
# written to reminder_sent_at, so a given occurrence reminds exactly once.
|
||||
to_notify: list[tuple[int, datetime]] = []
|
||||
for event in candidates:
|
||||
if event.recurrence:
|
||||
try:
|
||||
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
|
||||
occ = rule.after(now, inc=True)
|
||||
except Exception:
|
||||
logger.warning("Failed to expand RRULE for event %d reminder", event.id, exc_info=True)
|
||||
continue
|
||||
if occ is None:
|
||||
continue
|
||||
reminder_dt = occ - timedelta(minutes=event.reminder_minutes)
|
||||
if reminder_dt <= window_end and event.reminder_sent_at != occ:
|
||||
to_notify.append((event.id, occ))
|
||||
else:
|
||||
reminder_dt = event.start_dt - timedelta(minutes=event.reminder_minutes)
|
||||
if reminder_dt <= window_end:
|
||||
to_notify.append((event.id, event.start_dt))
|
||||
|
||||
if not to_notify:
|
||||
return
|
||||
|
||||
# Deliver via the in-app notification feed (push was removed in Phase 8).
|
||||
from scribe.services.notifications import create_in_app_notification
|
||||
|
||||
async with async_session() as session:
|
||||
for event_id, occurrence_start in to_notify:
|
||||
ev = (await session.execute(
|
||||
select(Event).where(Event.id == event_id)
|
||||
)).scalar_one_or_none()
|
||||
# Skip if this exact occurrence was already reminded (covers a
|
||||
# concurrent sweep and the one-shot already-sent case).
|
||||
if ev is None or ev.reminder_sent_at == occurrence_start:
|
||||
continue
|
||||
await create_in_app_notification(ev.user_id, "event_reminder", {
|
||||
"event_id": ev.id,
|
||||
"title": ev.title,
|
||||
"start_dt": occurrence_start.isoformat(),
|
||||
"url": "/calendar",
|
||||
})
|
||||
# Stamp the occurrence marker only after the notification is
|
||||
# created, so a delivery failure leaves it eligible to retry.
|
||||
ev.reminder_sent_at = occurrence_start
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _run_reminders(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_fire_reminders(), loop)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CalDAV pull sync job
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _run_caldav_sync() -> None:
|
||||
from scribe.services.caldav_sync import sync_all_users # noqa: PLC0415
|
||||
try:
|
||||
await sync_all_users()
|
||||
except Exception:
|
||||
logger.warning("CalDAV pull sync job failed", exc_info=True)
|
||||
|
||||
|
||||
def _run_caldav_sync_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_run_caldav_sync(), loop)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recurring-task spawn job
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _run_recurrence_spawn() -> None:
|
||||
from scribe.services.recurrence import spawn_recurring_tasks # noqa: PLC0415
|
||||
try:
|
||||
await spawn_recurring_tasks()
|
||||
except Exception:
|
||||
logger.warning("Recurring-task spawn job failed", exc_info=True)
|
||||
|
||||
|
||||
def _run_recurrence_spawn_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_run_recurrence_spawn(), loop)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def start_event_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
global _scheduler, _loop
|
||||
if _scheduler is not None:
|
||||
return
|
||||
_loop = loop
|
||||
_scheduler = BackgroundScheduler()
|
||||
|
||||
# Check reminders every 5 minutes
|
||||
_scheduler.add_job(
|
||||
_run_reminders,
|
||||
trigger=IntervalTrigger(minutes=5),
|
||||
args=[loop],
|
||||
id="event_reminders",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# CalDAV pull sync every hour
|
||||
_scheduler.add_job(
|
||||
_run_caldav_sync_threadsafe,
|
||||
trigger=IntervalTrigger(hours=1),
|
||||
args=[loop],
|
||||
id="caldav_pull_sync",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Spawn the next occurrence of due recurring tasks every 15 minutes.
|
||||
# Without this job, recurrence_next_spawn_at is armed on completion but
|
||||
# never drained, so recurring tasks never recur.
|
||||
_scheduler.add_job(
|
||||
_run_recurrence_spawn_threadsafe,
|
||||
trigger=IntervalTrigger(minutes=15),
|
||||
args=[loop],
|
||||
id="recurrence_spawn",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
_scheduler.start()
|
||||
logger.info(
|
||||
"Event scheduler started (reminders every 5m, CalDAV sync every 1h, "
|
||||
"recurring-task spawn every 15m)"
|
||||
)
|
||||
|
||||
|
||||
def stop_event_scheduler() -> None:
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
logger.info("Event scheduler stopped")
|
||||
@@ -0,0 +1,477 @@
|
||||
"""Internal event store service with CalDAV push sync.
|
||||
|
||||
Storage model: an event is anchored at ``start_dt`` and has an optional
|
||||
``duration_minutes``. The end of the event is *derived* via
|
||||
``Event.end_dt`` (a Python property), never stored. Callers may still
|
||||
pass ``end_dt`` on writes for ergonomic compatibility — the service
|
||||
converts to ``duration_minutes`` internally. This rules out the entire
|
||||
"end before start" bug class structurally (Fable #160 / migration
|
||||
0043). Open-ended events use ``duration_minutes = None``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from dateutil.rrule import rrulestr
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.event import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_duration(
|
||||
*,
|
||||
start_dt: datetime,
|
||||
end_dt: datetime | None,
|
||||
duration_minutes: int | None,
|
||||
) -> int | None:
|
||||
"""Reduce (end_dt, duration_minutes) inputs to a single canonical
|
||||
``duration_minutes`` value.
|
||||
|
||||
Resolution order:
|
||||
1. If ``duration_minutes`` is explicit, use it (validate >= 0).
|
||||
If ``end_dt`` is also given, validate the two agree.
|
||||
2. Otherwise, derive from ``end_dt - start_dt``.
|
||||
3. Otherwise None (point event with no end).
|
||||
|
||||
Raises ``ValueError`` for any invalid combination — duration < 0,
|
||||
end_dt < start_dt, or end_dt and duration_minutes inconsistent.
|
||||
"""
|
||||
if duration_minutes is not None:
|
||||
if duration_minutes < 0:
|
||||
raise ValueError(
|
||||
f"duration_minutes must be >= 0, got {duration_minutes}"
|
||||
)
|
||||
if end_dt is not None:
|
||||
expected = int((end_dt - start_dt).total_seconds() // 60)
|
||||
if expected != duration_minutes:
|
||||
raise ValueError(
|
||||
f"end_dt ({end_dt.isoformat()}) implies "
|
||||
f"{expected} minutes but duration_minutes={duration_minutes} "
|
||||
f"was passed; pass only one or make them agree."
|
||||
)
|
||||
return duration_minutes
|
||||
if end_dt is not None:
|
||||
delta_seconds = (end_dt - start_dt).total_seconds()
|
||||
if delta_seconds < 0:
|
||||
raise ValueError(
|
||||
f"end_dt ({end_dt.isoformat()}) must be at or after "
|
||||
f"start_dt ({start_dt.isoformat()}); pass end_dt=None "
|
||||
f"or omit it for point events."
|
||||
)
|
||||
return int(delta_seconds // 60)
|
||||
return None
|
||||
|
||||
|
||||
async def _localize_naive(user_id: int, dt: datetime | None) -> datetime | None:
|
||||
"""Anchor a naive datetime in the user's timezone; pass tz-aware through.
|
||||
|
||||
Naive datetimes are the user's local wall-clock time (the MCP create/update
|
||||
tools combine date+time without a zone). Attaching the user's tzinfo lets
|
||||
asyncpg store the correct UTC instant, matching the REST/UI path.
|
||||
"""
|
||||
if dt is not None and dt.tzinfo is None:
|
||||
from scribe.services.tz import get_user_tz # noqa: PLC0415
|
||||
return dt.replace(tzinfo=await get_user_tz(user_id))
|
||||
return dt
|
||||
|
||||
|
||||
async def create_event(
|
||||
user_id: int,
|
||||
title: str,
|
||||
start_dt: datetime,
|
||||
end_dt: datetime | None = None,
|
||||
duration_minutes: int | None = None,
|
||||
all_day: bool = False,
|
||||
description: str = "",
|
||||
location: str = "",
|
||||
color: str = "",
|
||||
recurrence: str | None = None,
|
||||
project_id: int | None = None,
|
||||
reminder_minutes: int | None = None,
|
||||
# ``duration`` is a legacy alias kept for the calendar tool layer
|
||||
# and CalDAV pass-through callers; promotes to duration_minutes
|
||||
# when duration_minutes isn't otherwise specified.
|
||||
duration: int | None = None,
|
||||
attendees: list[str] | None = None,
|
||||
calendar_name: str | None = None,
|
||||
) -> Event:
|
||||
"""Create an event in the DB, then fire a CalDAV push task.
|
||||
|
||||
Either ``end_dt`` or ``duration_minutes`` may be supplied; the
|
||||
service converts to ``duration_minutes`` internally. Raises
|
||||
``ValueError`` on invalid combinations (negative duration, end
|
||||
before start, end/duration disagreement).
|
||||
"""
|
||||
if duration is not None and duration_minutes is None:
|
||||
duration_minutes = duration
|
||||
# Canonical localization point: a naive datetime (e.g. from the MCP tool's
|
||||
# date+time split) is the user's wall-clock time, so anchor it in their
|
||||
# timezone before storage. tz-aware inputs (REST, CalDAV pass-through) are
|
||||
# left untouched. Without this, MCP-created events landed at the same
|
||||
# wall-clock numerals in UTC and drifted from UI-created ones by the offset.
|
||||
start_dt = await _localize_naive(user_id, start_dt)
|
||||
end_dt = await _localize_naive(user_id, end_dt)
|
||||
duration_minutes = _normalize_duration(
|
||||
start_dt=start_dt, end_dt=end_dt, duration_minutes=duration_minutes,
|
||||
)
|
||||
uid = str(uuid.uuid4())
|
||||
async with async_session() as session:
|
||||
event = Event(
|
||||
user_id=user_id,
|
||||
uid=uid,
|
||||
title=title,
|
||||
start_dt=start_dt,
|
||||
duration_minutes=duration_minutes,
|
||||
all_day=all_day,
|
||||
description=description,
|
||||
location=location,
|
||||
color=color,
|
||||
recurrence=recurrence,
|
||||
project_id=project_id,
|
||||
reminder_minutes=reminder_minutes,
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
await session.refresh(event)
|
||||
|
||||
extra_fields = {
|
||||
"duration": duration_minutes,
|
||||
"reminder_minutes": reminder_minutes,
|
||||
"attendees": attendees,
|
||||
"calendar_name": calendar_name,
|
||||
}
|
||||
asyncio.create_task(_push_create(event, user_id, extra_fields))
|
||||
return event
|
||||
|
||||
|
||||
async def get_event(user_id: int, event_id: int) -> Event | None:
|
||||
"""Return event owned by user_id, or None."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.id == event_id, Event.user_id == user_id, Event.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_events(
|
||||
user_id: int,
|
||||
date_from: datetime,
|
||||
date_to: datetime,
|
||||
) -> list[dict]:
|
||||
"""List events for user_id that overlap [date_from, date_to].
|
||||
|
||||
Recurring events (with an RRULE recurrence string) are expanded into
|
||||
individual occurrences within the range. Non-recurring events are
|
||||
returned as-is. All results are sorted by start time and returned as
|
||||
dicts (same shape as ``Event.to_dict()``).
|
||||
|
||||
Filtering strategy: a coarse SQL prefilter (events that start on or
|
||||
before ``date_to``), then refine in Python using the event's derived
|
||||
end (``start_dt + duration_minutes``). Doing the end-of-event math
|
||||
in SQL would require Postgres-specific interval arithmetic; the
|
||||
Python-side refinement is a few row-loops over a small per-user
|
||||
result set, which is fine for personal-scale data and avoids
|
||||
coupling the query to a specific dialect.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.user_id == user_id,
|
||||
Event.deleted_at.is_(None),
|
||||
or_(
|
||||
Event.recurrence.isnot(None),
|
||||
Event.start_dt <= date_to,
|
||||
),
|
||||
)
|
||||
.order_by(Event.start_dt)
|
||||
)
|
||||
events = list(result.scalars().all())
|
||||
|
||||
items: list[dict] = []
|
||||
for event in events:
|
||||
if event.recurrence:
|
||||
duration = (
|
||||
timedelta(minutes=event.duration_minutes)
|
||||
if event.duration_minutes is not None
|
||||
else None
|
||||
)
|
||||
try:
|
||||
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
|
||||
occurrences = rule.between(date_from, date_to, inc=True)
|
||||
except Exception:
|
||||
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence)
|
||||
# Fall back to canonical event row; still apply the
|
||||
# window check so a far-future canonical row doesn't
|
||||
# leak into today's list.
|
||||
if date_from <= event.start_dt <= date_to:
|
||||
items.append(event.to_dict())
|
||||
continue
|
||||
|
||||
base = event.to_dict()
|
||||
for occ in occurrences:
|
||||
if occ.tzinfo is None:
|
||||
occ = occ.replace(tzinfo=timezone.utc)
|
||||
occurrence_dict = dict(base)
|
||||
occurrence_dict["start_dt"] = occ.isoformat()
|
||||
if duration is not None:
|
||||
occurrence_dict["end_dt"] = (occ + duration).isoformat()
|
||||
items.append(occurrence_dict)
|
||||
continue
|
||||
|
||||
# Non-recurring: refine the coarse prefilter in Python using the
|
||||
# derived end_dt. A point event (duration None) is included when
|
||||
# its start is at or after date_from. A timed event is included
|
||||
# when its end is at or after date_from.
|
||||
derived_end = event.end_dt
|
||||
if derived_end is None:
|
||||
if event.start_dt >= date_from:
|
||||
items.append(event.to_dict())
|
||||
else:
|
||||
if derived_end >= date_from:
|
||||
items.append(event.to_dict())
|
||||
|
||||
items.sort(key=lambda x: x["start_dt"])
|
||||
return items
|
||||
|
||||
|
||||
async def search_events(
|
||||
user_id: int,
|
||||
query: str,
|
||||
days_ahead: int = 90,
|
||||
include_past: bool = False,
|
||||
) -> list[Event]:
|
||||
"""Search events by keyword in title, description, or location."""
|
||||
now = datetime.now(timezone.utc)
|
||||
q = f"%{query}%"
|
||||
async with async_session() as session:
|
||||
where = [
|
||||
Event.user_id == user_id,
|
||||
Event.deleted_at.is_(None),
|
||||
or_(
|
||||
Event.title.ilike(q),
|
||||
Event.description.ilike(q),
|
||||
Event.location.ilike(q),
|
||||
),
|
||||
]
|
||||
if not include_past:
|
||||
date_to = now + timedelta(days=days_ahead)
|
||||
where.extend([Event.start_dt >= now, Event.start_dt <= date_to])
|
||||
result = await session.execute(
|
||||
select(Event).where(*where).order_by(Event.start_dt)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
||||
"""Partial update. Returns updated event or None if not found.
|
||||
|
||||
Accepts ``end_dt`` or ``duration_minutes`` (or both, validated for
|
||||
agreement). The service converts to ``duration_minutes`` before
|
||||
persisting; ``end_dt`` is never stored. Raises ``ValueError`` for
|
||||
invalid combinations against the post-update state.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.id == event_id, Event.user_id == user_id,
|
||||
Event.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
if event is None:
|
||||
return None
|
||||
old_title = event.title # capture before mutation for CalDAV lookup
|
||||
|
||||
# Localize a naive start_dt patch to the user's timezone (same canonical
|
||||
# rule as create_event) before it's used or persisted.
|
||||
if fields.get("start_dt") is not None:
|
||||
fields["start_dt"] = await _localize_naive(user_id, fields["start_dt"])
|
||||
|
||||
# Resolve any end_dt/duration_minutes inputs against the
|
||||
# post-update start_dt. If neither is in the patch, leave the
|
||||
# existing duration_minutes alone.
|
||||
post_update_start = (
|
||||
fields["start_dt"]
|
||||
if fields.get("start_dt") is not None
|
||||
else event.start_dt
|
||||
)
|
||||
if "end_dt" in fields or "duration_minutes" in fields:
|
||||
new_end = fields.pop("end_dt", None)
|
||||
new_duration = fields.pop("duration_minutes", None)
|
||||
# If end_dt is in the patch but explicitly None, that's a
|
||||
# clear → duration_minutes = None. Same shape duration_minutes=None.
|
||||
if new_end is None and new_duration is None:
|
||||
fields["duration_minutes"] = None
|
||||
else:
|
||||
fields["duration_minutes"] = _normalize_duration(
|
||||
start_dt=post_update_start,
|
||||
end_dt=new_end,
|
||||
duration_minutes=new_duration,
|
||||
)
|
||||
|
||||
allowed = {
|
||||
"title", "start_dt", "duration_minutes", "all_day",
|
||||
"description", "location", "color", "recurrence",
|
||||
"project_id", "reminder_minutes",
|
||||
}
|
||||
# Nullable fields callers can explicitly clear by passing None
|
||||
nullable = {
|
||||
"duration_minutes", "recurrence", "project_id",
|
||||
"reminder_minutes",
|
||||
}
|
||||
for key, value in fields.items():
|
||||
if key in allowed and (value is not None or key in nullable):
|
||||
setattr(event, key, value)
|
||||
# Re-arm the reminder when the timing changes, so an event moved to a
|
||||
# new (future) time — or given a new lead time — fires again instead of
|
||||
# being permanently suppressed by a stale reminder_sent_at.
|
||||
if "start_dt" in fields or "reminder_minutes" in fields:
|
||||
event.reminder_sent_at = None
|
||||
await session.commit()
|
||||
await session.refresh(event)
|
||||
|
||||
asyncio.create_task(_push_update(event, user_id, old_title=old_title))
|
||||
return event
|
||||
|
||||
|
||||
async def delete_event(user_id: int, event_id: int) -> None:
|
||||
"""Delete event. Fires CalDAV delete push if caldav_uid is set."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(Event.id == event_id, Event.user_id == user_id)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
if event is None:
|
||||
return
|
||||
caldav_uid = event.caldav_uid
|
||||
event_title = event.title # needed to find the event on CalDAV by title
|
||||
await session.delete(event)
|
||||
await session.commit()
|
||||
|
||||
if caldav_uid:
|
||||
asyncio.create_task(_push_delete(caldav_uid, event_title, user_id))
|
||||
|
||||
|
||||
async def find_events_by_query(user_id: int, query: str) -> list[Event]:
|
||||
"""ILIKE search on title — used by AI update/delete tools.
|
||||
|
||||
Returns upcoming events first (start_dt >= now), falling back to
|
||||
past events so the AI operates on the most relevant match.
|
||||
"""
|
||||
q = f"%{query}%"
|
||||
now = datetime.now(timezone.utc)
|
||||
async with async_session() as session:
|
||||
# Prefer events at or after now; fall back to past events
|
||||
upcoming = (await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
Event.deleted_at.is_(None),
|
||||
Event.title.ilike(q),
|
||||
Event.start_dt >= now,
|
||||
).order_by(Event.start_dt)
|
||||
)).scalars().all()
|
||||
if upcoming:
|
||||
return list(upcoming)
|
||||
past = (await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
Event.deleted_at.is_(None),
|
||||
Event.title.ilike(q),
|
||||
Event.start_dt < now,
|
||||
).order_by(Event.start_dt.desc())
|
||||
)).scalars().all()
|
||||
return list(past)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CalDAV push helpers (fire-and-forget)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _push_create(event: Event, user_id: int, extra: dict) -> None:
|
||||
try:
|
||||
from scribe.services.caldav import (
|
||||
create_event as caldav_create,
|
||||
is_caldav_configured,
|
||||
)
|
||||
if not await is_caldav_configured(user_id):
|
||||
return
|
||||
derived_end = event.end_dt # property: start + duration_minutes
|
||||
await caldav_create(
|
||||
user_id=user_id,
|
||||
title=event.title,
|
||||
start=event.start_dt.isoformat(),
|
||||
end=derived_end.isoformat() if derived_end else None,
|
||||
description=event.description or None,
|
||||
location=event.location or None,
|
||||
all_day=event.all_day,
|
||||
recurrence=event.recurrence,
|
||||
uid=event.uid,
|
||||
duration=extra.get("duration"),
|
||||
reminder_minutes=extra.get("reminder_minutes"),
|
||||
attendees=extra.get("attendees"),
|
||||
calendar_name=extra.get("calendar_name"),
|
||||
)
|
||||
# Mark as synced
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(Event.id == event.id)
|
||||
)
|
||||
ev = result.scalar_one_or_none()
|
||||
if ev:
|
||||
ev.caldav_uid = event.uid
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.warning("CalDAV push (create) failed for event %d", event.id, exc_info=True)
|
||||
|
||||
|
||||
async def _push_update(event: Event, user_id: int, old_title: str = "") -> None:
|
||||
"""Push an update to CalDAV. Uses old_title to locate the event by its pre-rename SUMMARY."""
|
||||
if not event.caldav_uid:
|
||||
return
|
||||
try:
|
||||
from scribe.services.caldav import (
|
||||
update_event as caldav_update,
|
||||
is_caldav_configured,
|
||||
)
|
||||
if not await is_caldav_configured(user_id):
|
||||
return
|
||||
# Use old_title so CalDAV can find the event even if the title was changed
|
||||
query_title = old_title or event.title
|
||||
derived_end = event.end_dt
|
||||
await caldav_update(
|
||||
user_id=user_id,
|
||||
query=query_title,
|
||||
title=event.title,
|
||||
start=event.start_dt.isoformat(),
|
||||
end=derived_end.isoformat() if derived_end else None,
|
||||
description=event.description or None,
|
||||
location=event.location or None,
|
||||
# Propagate the (possibly cleared) RRULE so a local recurrence edit
|
||||
# isn't overwritten by the stale remote rule on the next pull.
|
||||
recurrence=event.recurrence,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("CalDAV push (update) failed for event %d", event.id, exc_info=True)
|
||||
|
||||
|
||||
async def _push_delete(caldav_uid: str, event_title: str, user_id: int) -> None:
|
||||
"""Push a delete to CalDAV. Uses event_title to locate the event by SUMMARY."""
|
||||
try:
|
||||
from scribe.services.caldav import (
|
||||
delete_event as caldav_delete,
|
||||
is_caldav_configured,
|
||||
)
|
||||
if not await is_caldav_configured(user_id):
|
||||
return
|
||||
await caldav_delete(user_id=user_id, query=event_title)
|
||||
except Exception:
|
||||
logger.warning("CalDAV push (delete) failed for uid %s", caldav_uid, exc_info=True)
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Knowledge service — unified query across notes, tasks, plans, and processes."""
|
||||
"""Knowledge service — unified query across notes, people, places, and lists."""
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
@@ -12,16 +12,46 @@ _SNIPPET_LEN = 200
|
||||
|
||||
|
||||
def _note_to_item(note: Note) -> dict:
|
||||
meta = note.entity_meta or {}
|
||||
item: dict = {
|
||||
"id": note.id,
|
||||
"note_type": note.note_type or "note",
|
||||
"note_type": note.entity_type,
|
||||
"title": note.title,
|
||||
"snippet": (note.body or "")[:_SNIPPET_LEN],
|
||||
"tags": note.tags or [],
|
||||
"project_id": note.project_id,
|
||||
"metadata": meta,
|
||||
"created_at": note.created_at.isoformat(),
|
||||
"updated_at": note.updated_at.isoformat(),
|
||||
}
|
||||
# Type-specific convenience fields
|
||||
if note.entity_type == "person":
|
||||
item["relationship"] = meta.get("relationship", "")
|
||||
item["email"] = meta.get("email", "")
|
||||
item["phone"] = meta.get("phone", "")
|
||||
item["birthday"] = meta.get("birthday", "")
|
||||
item["organization"] = meta.get("organization", "")
|
||||
item["address"] = meta.get("address", "")
|
||||
elif note.entity_type == "place":
|
||||
item["address"] = meta.get("address", "")
|
||||
item["phone"] = meta.get("phone", "")
|
||||
item["hours"] = meta.get("hours", "")
|
||||
item["website"] = meta.get("website", "")
|
||||
item["category"] = meta.get("category", "")
|
||||
elif note.entity_type == "list":
|
||||
# Parse markdown task list syntax into structured items
|
||||
body = note.body or ""
|
||||
list_items = []
|
||||
for line in body.split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "):
|
||||
checked_item = not stripped.startswith("- [ ] ")
|
||||
list_items.append({"text": stripped[6:], "checked": checked_item})
|
||||
item["list_items"] = list_items
|
||||
item["item_count"] = len(list_items)
|
||||
item["checked_count"] = sum(1 for i in list_items if i["checked"])
|
||||
item["body"] = body
|
||||
|
||||
# Task fields — override note_type and add status/priority/due_date
|
||||
if note.is_task:
|
||||
item["note_type"] = "task"
|
||||
@@ -160,7 +190,7 @@ async def _semantic_knowledge_search(
|
||||
continue
|
||||
elif note_type == "plan" and (not note.is_task or note.task_kind != "plan"):
|
||||
continue
|
||||
elif note_type and note_type not in ("task", "plan") and note.note_type != note_type:
|
||||
elif note_type and note_type not in ("task", "plan") and note.entity_type != note_type:
|
||||
continue
|
||||
if tags and not all(t in (note.tags or []) for t in tags):
|
||||
continue
|
||||
@@ -207,7 +237,7 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d
|
||||
.where(Note.user_id == user_id)
|
||||
.where(Note.status.is_(None))
|
||||
.where(Note.deleted_at.is_(None))
|
||||
.where(Note.note_type.in_(["note", "process"]))
|
||||
.where(Note.note_type.in_(["note", "person", "place", "list", "process"]))
|
||||
.group_by(Note.note_type)
|
||||
)
|
||||
if tags:
|
||||
@@ -243,9 +273,9 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d
|
||||
plan_stmt = plan_stmt.where(Note.tags.contains([tag]))
|
||||
counts["plan"] = (await session.execute(plan_stmt)).scalar_one()
|
||||
|
||||
for t in ("note", "task", "plan", "process"):
|
||||
for t in ("note", "person", "place", "list", "task", "plan", "process"):
|
||||
counts.setdefault(t, 0)
|
||||
counts["total"] = sum(counts[t] for t in ("note", "task", "process"))
|
||||
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task", "process"))
|
||||
return counts
|
||||
|
||||
|
||||
@@ -305,3 +335,42 @@ async def get_knowledge_by_ids(user_id: int, ids: list[int]) -> list[dict]:
|
||||
rows = list((await session.execute(stmt)).scalars().all())
|
||||
by_id = {n.id: n for n in rows}
|
||||
return [_note_to_item(by_id[i]) for i in ids if i in by_id]
|
||||
|
||||
|
||||
async def get_people_and_places_context(user_id: int) -> str:
|
||||
"""Return a compact summary of known people and places for LLM system prompt injection."""
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(Note)
|
||||
.where(Note.user_id == user_id)
|
||||
.where(Note.note_type.in_(["person", "place"]))
|
||||
.where(Note.status.is_(None))
|
||||
.where(Note.deleted_at.is_(None))
|
||||
.order_by(Note.title.asc())
|
||||
.limit(50)
|
||||
)
|
||||
rows = list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
if not rows:
|
||||
return ""
|
||||
|
||||
people = [n for n in rows if n.entity_type == "person"]
|
||||
places = [n for n in rows if n.entity_type == "place"]
|
||||
|
||||
lines = []
|
||||
if people:
|
||||
parts = []
|
||||
for p in people:
|
||||
meta = p.entity_meta or {}
|
||||
rel = meta.get("relationship", "")
|
||||
parts.append(f"{p.title}" + (f" ({rel})" if rel else ""))
|
||||
lines.append("Known people: " + ", ".join(parts))
|
||||
if places:
|
||||
parts = []
|
||||
for p in places:
|
||||
meta = p.entity_meta or {}
|
||||
addr = meta.get("address", "")
|
||||
parts.append(f"{p.title}" + (f" – {addr}" if addr else ""))
|
||||
lines.append("Known places: " + "; ".join(parts))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -63,6 +63,7 @@ async def create_note(
|
||||
due_date: date | None = None,
|
||||
recurrence_rule: dict | None = None,
|
||||
note_type: str = "note",
|
||||
entity_meta: dict | None = None,
|
||||
task_kind: str = "work",
|
||||
arose_from_id: int | None = None,
|
||||
) -> Note:
|
||||
@@ -106,6 +107,7 @@ async def create_note(
|
||||
due_date=due_date,
|
||||
recurrence_rule=recurrence_rule,
|
||||
note_type=note_type,
|
||||
entity_meta=entity_meta,
|
||||
task_kind=task_kind,
|
||||
arose_from_id=arose_from_id,
|
||||
)
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Scheduler for recurring-task spawning.
|
||||
|
||||
Every 15 minutes, creates the next occurrence of any recurring task whose spawn
|
||||
time has arrived — draining `recurrence_next_spawn_at`, which is armed on task
|
||||
completion. Without this job, recurring tasks would never recur.
|
||||
|
||||
Uses the BackgroundScheduler pattern shared with the other *_scheduler modules.
|
||||
(Formerly event_scheduler.py, which also ran event reminders + CalDAV sync;
|
||||
those were removed when the calendar surface was retired.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
async def _run_recurrence_spawn() -> None:
|
||||
from scribe.services.recurrence import spawn_recurring_tasks # noqa: PLC0415
|
||||
try:
|
||||
await spawn_recurring_tasks()
|
||||
except Exception:
|
||||
logger.warning("Recurring-task spawn job failed", exc_info=True)
|
||||
|
||||
|
||||
def _run_recurrence_spawn_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_run_recurrence_spawn(), loop)
|
||||
|
||||
|
||||
def start_recurrence_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
global _scheduler, _loop
|
||||
if _scheduler is not None:
|
||||
return
|
||||
_loop = loop
|
||||
_scheduler = BackgroundScheduler()
|
||||
|
||||
# Spawn the next occurrence of due recurring tasks every 15 minutes.
|
||||
# Without this job, recurrence_next_spawn_at is armed on completion but
|
||||
# never drained, so recurring tasks never recur.
|
||||
_scheduler.add_job(
|
||||
_run_recurrence_spawn_threadsafe,
|
||||
trigger=IntervalTrigger(minutes=15),
|
||||
args=[loop],
|
||||
id="recurrence_spawn",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
_scheduler.start()
|
||||
logger.info("Recurrence scheduler started (recurring-task spawn every 15m)")
|
||||
|
||||
|
||||
def stop_recurrence_scheduler() -> None:
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
logger.info("Recurrence scheduler stopped")
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy import or_, select, update
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.event import Event
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.milestone import Milestone
|
||||
from scribe.models.rulebook import Rulebook, RulebookTopic, Rule
|
||||
@@ -22,6 +23,7 @@ from scribe.models.rulebook import Rulebook, RulebookTopic, Rule
|
||||
_MODEL_FOR = {
|
||||
"note": Note,
|
||||
"task": Note,
|
||||
"event": Event,
|
||||
"project": Project,
|
||||
"milestone": Milestone,
|
||||
"rulebook": Rulebook,
|
||||
@@ -57,7 +59,7 @@ def _owner_clause(model, user_id: int):
|
||||
select(Project.id).where(Project.user_id == user_id)
|
||||
),
|
||||
)
|
||||
# Note, Project, Milestone all carry user_id directly.
|
||||
# Note, Event, Project, Milestone all carry user_id directly.
|
||||
return model.user_id == user_id
|
||||
|
||||
|
||||
@@ -119,6 +121,8 @@ async def _cascade(session, user_id: int, etype: str, eid: int, batch: str, now)
|
||||
frontier = [c for c in children if c not in ids]
|
||||
ids.extend(frontier)
|
||||
await _set(session, Note, [Note.user_id == user_id, Note.id.in_(ids)], batch, now)
|
||||
elif etype == "event":
|
||||
await _set(session, Event, [Event.user_id == user_id, Event.id == eid], batch, now)
|
||||
elif etype == "rulebook":
|
||||
topic_ids = (await session.execute(
|
||||
select(RulebookTopic.id)
|
||||
@@ -145,18 +149,35 @@ async def delete(user_id: int, entity_type: str, entity_id: int) -> str | None:
|
||||
"""
|
||||
batch = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc)
|
||||
caldav_event: tuple[str, str] | None = None
|
||||
async with async_session() as session:
|
||||
if not await _exists_alive(session, user_id, entity_type, entity_id):
|
||||
return None
|
||||
# Capture CalDAV linkage before soft-deleting so we can propagate the
|
||||
# deletion to the external server (the row stays present locally).
|
||||
if entity_type == "event":
|
||||
row = (await session.execute(
|
||||
select(Event.caldav_uid, Event.title).where(
|
||||
Event.id == entity_id, Event.user_id == user_id
|
||||
)
|
||||
)).first()
|
||||
if row and row[0]:
|
||||
caldav_event = (row[0], row[1])
|
||||
await _cascade(session, user_id, entity_type, entity_id, batch, now)
|
||||
await session.commit()
|
||||
# Without this the soft-delete only hides the event locally and the remote
|
||||
# copy lingers forever (and re-appears on any client syncing that server).
|
||||
if caldav_event:
|
||||
import asyncio
|
||||
from scribe.services.events import _push_delete
|
||||
asyncio.create_task(_push_delete(caldav_event[0], caldav_event[1], user_id))
|
||||
return batch
|
||||
|
||||
|
||||
# All soft-deletable models, and their trash-listing type label.
|
||||
_ALL = [Note, Project, Milestone, Rulebook, RulebookTopic, Rule]
|
||||
_ALL = [Note, Event, Project, Milestone, Rulebook, RulebookTopic, Rule]
|
||||
_TYPE = {
|
||||
Note: "note", Project: "project", Milestone: "milestone",
|
||||
Note: "note", Event: "event", Project: "project", Milestone: "milestone",
|
||||
Rulebook: "rulebook", RulebookTopic: "topic", Rule: "rule",
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
def test_event_model_has_new_columns():
|
||||
from scribe.models.event import Event
|
||||
cols = {c.key for c in Event.__table__.columns}
|
||||
assert "caldav_uid" in cols
|
||||
assert "color" in cols
|
||||
|
||||
|
||||
def test_event_to_dict_includes_new_fields():
|
||||
from scribe.models.event import Event
|
||||
from datetime import datetime, timezone
|
||||
e = Event(
|
||||
user_id=1, uid="test-uid", title="Test",
|
||||
start_dt=datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc),
|
||||
caldav_uid="sync-uid", color="#6366f1",
|
||||
)
|
||||
d = e.to_dict()
|
||||
assert d["caldav_uid"] == "sync-uid"
|
||||
assert d["color"] == "#6366f1"
|
||||
|
||||
|
||||
def test_caldav_create_event_accepts_uid_param():
|
||||
"""caldav.create_event signature must accept an optional uid param."""
|
||||
import inspect
|
||||
from scribe.services.caldav import create_event
|
||||
sig = inspect.signature(create_event)
|
||||
assert "uid" in sig.parameters
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Route-level tests for the events blueprint.
|
||||
|
||||
Full HTTP integration tests require a live DB (not available in unit test
|
||||
environment). These tests cover structural correctness and the route
|
||||
module's public interface; ownership enforcement is covered by the service
|
||||
tests in test_events_service.py.
|
||||
"""
|
||||
|
||||
|
||||
def test_events_blueprint_registered():
|
||||
"""events_bp must be importable and have the correct name."""
|
||||
from scribe.routes.events import events_bp
|
||||
assert events_bp.name == "events"
|
||||
assert events_bp.url_prefix == "/api/events"
|
||||
|
||||
|
||||
def test_events_blueprint_has_five_routes():
|
||||
"""Blueprint must declare routes for GET/POST '' and GET/PATCH/DELETE '/<id>'."""
|
||||
from scribe.routes.events import events_bp
|
||||
methods_by_rule: dict[str, set[str]] = {}
|
||||
for rule in events_bp.deferred_functions:
|
||||
pass # deferred; inspect via url_map after binding
|
||||
# Import routes module to confirm all 5 view functions exist
|
||||
from scribe.routes import events as events_module
|
||||
assert callable(events_module.list_events)
|
||||
assert callable(events_module.create_event)
|
||||
assert callable(events_module.get_event)
|
||||
assert callable(events_module.update_event)
|
||||
assert callable(events_module.delete_event)
|
||||
|
||||
|
||||
def test_events_blueprint_registered_in_app():
|
||||
"""events_bp must be registered in the app factory."""
|
||||
from scribe.app import create_app
|
||||
app = create_app()
|
||||
# Check the blueprint is present in the app's blueprints dict
|
||||
assert "events" in app.blueprints
|
||||
|
||||
|
||||
def test_events_service_ownership_enforced_on_get():
|
||||
"""get_event returns None for a different user — route will 404."""
|
||||
# Ownership is enforced by the service filtering by user_id.
|
||||
# The service returns None when the event belongs to a different user,
|
||||
# and the route converts that to a 404 response.
|
||||
import inspect
|
||||
from scribe.services import events as events_svc
|
||||
sig = inspect.signature(events_svc.get_event)
|
||||
assert "user_id" in sig.parameters
|
||||
assert "event_id" in sig.parameters
|
||||
|
||||
|
||||
def test_events_service_ownership_enforced_on_update():
|
||||
"""update_event takes user_id — route passes current user's id."""
|
||||
import inspect
|
||||
from scribe.services import events as events_svc
|
||||
sig = inspect.signature(events_svc.update_event)
|
||||
assert "user_id" in sig.parameters
|
||||
assert "event_id" in sig.parameters
|
||||
|
||||
|
||||
def test_events_service_ownership_enforced_on_delete():
|
||||
"""delete_event takes user_id — route verifies ownership before deleting."""
|
||||
import inspect
|
||||
from scribe.services import events as events_svc
|
||||
sig = inspect.signature(events_svc.delete_event)
|
||||
assert "user_id" in sig.parameters
|
||||
assert "event_id" in sig.parameters
|
||||
@@ -0,0 +1,326 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def _make_mock_session():
|
||||
mock_session = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.refresh = AsyncMock()
|
||||
return mock_session
|
||||
|
||||
|
||||
def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
|
||||
caldav_uid="", color="", duration_minutes=60):
|
||||
e = MagicMock()
|
||||
e.id = id
|
||||
e.user_id = user_id
|
||||
e.uid = uid
|
||||
e.title = title
|
||||
e.caldav_uid = caldav_uid
|
||||
e.color = color
|
||||
e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc)
|
||||
e.duration_minutes = duration_minutes
|
||||
# end_dt is derived; mirror the property's behavior on the mock so
|
||||
# service code that reads `event.end_dt` gets a sensible value.
|
||||
if duration_minutes is None:
|
||||
e.end_dt = None
|
||||
else:
|
||||
from datetime import timedelta
|
||||
e.end_dt = e.start_dt + timedelta(minutes=duration_minutes)
|
||||
e.all_day = False
|
||||
e.description = ""
|
||||
e.location = ""
|
||||
e.recurrence = None
|
||||
e.project_id = None
|
||||
e.to_dict.return_value = {
|
||||
"id": id, "uid": uid, "title": title,
|
||||
"caldav_uid": caldav_uid, "color": color,
|
||||
"start_dt": e.start_dt.isoformat(),
|
||||
"end_dt": e.end_dt.isoformat() if e.end_dt else None,
|
||||
"duration_minutes": duration_minutes,
|
||||
}
|
||||
return e
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_stores_to_db():
|
||||
mock_session = _make_mock_session()
|
||||
with patch("scribe.services.events.async_session") as mock_cls, \
|
||||
patch("scribe.services.events.asyncio.create_task") as mock_task:
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.events import create_event
|
||||
result = await create_event(
|
||||
user_id=1,
|
||||
title="Dentist",
|
||||
start_dt=datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
assert mock_session.add.called
|
||||
assert mock_session.commit.called
|
||||
# CalDAV push background task should be scheduled
|
||||
assert mock_task.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_events_by_query_returns_ilike_results():
|
||||
mock_event = _make_mock_event(title="Team Meeting")
|
||||
mock_session = _make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [mock_event]
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("scribe.services.events.async_session") as mock_cls:
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.events import find_events_by_query
|
||||
results = await find_events_by_query(user_id=1, query="meeting")
|
||||
assert len(results) == 1
|
||||
assert results[0].title == "Team Meeting"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_returns_events_in_range():
|
||||
mock_event = _make_mock_event()
|
||||
mock_session = _make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [mock_event]
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("scribe.services.events.async_session") as mock_cls:
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.events import list_events
|
||||
results = await list_events(
|
||||
user_id=1,
|
||||
date_from=datetime(2026, 3, 1, tzinfo=timezone.utc),
|
||||
date_to=datetime(2026, 3, 31, tzinfo=timezone.utc),
|
||||
)
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_event_fires_caldav_push_when_uid_set():
|
||||
mock_event = _make_mock_event(caldav_uid="sync-uid")
|
||||
mock_session = _make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = mock_event
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("scribe.services.events.async_session") as mock_cls, \
|
||||
patch("scribe.services.events.asyncio.create_task") as mock_task:
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.events import delete_event
|
||||
await delete_event(user_id=1, event_id=1)
|
||||
# Push task fired because caldav_uid is set
|
||||
assert mock_task.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_fires_caldav_push():
|
||||
mock_event = _make_mock_event(caldav_uid="sync-uid")
|
||||
mock_session = _make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = mock_event
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("scribe.services.events.async_session") as mock_cls, \
|
||||
patch("scribe.services.events.asyncio.create_task") as mock_task:
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.events import update_event
|
||||
await update_event(user_id=1, event_id=1, title="Updated Title")
|
||||
assert mock_task.called
|
||||
|
||||
|
||||
# ── Duration-model write-side guarantees (Fable #160) ─────────────────────────
|
||||
|
||||
|
||||
def test_normalize_duration_from_end_dt():
|
||||
"""end_dt sugar converts to a positive minute count anchored on start."""
|
||||
from scribe.services.events import _normalize_duration
|
||||
start = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 5, 1, 9, 30, tzinfo=timezone.utc)
|
||||
assert _normalize_duration(start_dt=start, end_dt=end, duration_minutes=None) == 90
|
||||
|
||||
|
||||
def test_normalize_duration_zero_is_valid_point_event():
|
||||
"""end_dt == start_dt → duration 0. The point-with-zero-duration case
|
||||
is rare but legal (e.g. an instant marker); the duration model treats
|
||||
it the same as duration None for display purposes."""
|
||||
from scribe.services.events import _normalize_duration
|
||||
same = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc)
|
||||
assert _normalize_duration(start_dt=same, end_dt=same, duration_minutes=None) == 0
|
||||
|
||||
|
||||
def test_normalize_duration_rejects_end_before_start():
|
||||
"""The exact 2026-04-29 prod failure: end 32 days before start.
|
||||
The duration model makes this inexpressible at the schema level
|
||||
via a CHECK constraint, but write-path callers still get a
|
||||
helpful ValueError if they construct an inconsistent (start, end)
|
||||
pair via the end_dt sugar."""
|
||||
from scribe.services.events import _normalize_duration
|
||||
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
|
||||
with pytest.raises(ValueError, match="at or after start_dt"):
|
||||
_normalize_duration(
|
||||
start_dt=start, end_dt=end_before, duration_minutes=None,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_duration_rejects_negative_duration():
|
||||
"""Direct duration_minutes < 0 is rejected. Mirrors the DB CHECK
|
||||
constraint at the service boundary so callers get a clean error
|
||||
rather than a constraint violation from psycopg."""
|
||||
from scribe.services.events import _normalize_duration
|
||||
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
with pytest.raises(ValueError, match="must be >= 0"):
|
||||
_normalize_duration(start_dt=start, end_dt=None, duration_minutes=-15)
|
||||
|
||||
|
||||
def test_normalize_duration_rejects_inconsistent_end_and_duration():
|
||||
"""If a caller passes both end_dt AND duration_minutes that disagree,
|
||||
the inconsistency is surfaced rather than silently picking one."""
|
||||
from scribe.services.events import _normalize_duration
|
||||
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 5, 1, 13, 0, tzinfo=timezone.utc) # implies 60 min
|
||||
with pytest.raises(ValueError, match="implies 60 minutes"):
|
||||
_normalize_duration(
|
||||
start_dt=start, end_dt=end, duration_minutes=30,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_duration_none_for_open_ended():
|
||||
"""Both inputs None → None duration (open-ended event)."""
|
||||
from scribe.services.events import _normalize_duration
|
||||
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
assert _normalize_duration(
|
||||
start_dt=start, end_dt=None, duration_minutes=None,
|
||||
) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_rejects_end_before_start():
|
||||
"""Service-level rejection — same scenario as the prod bug, surfaced
|
||||
cleanly for tool / route callers via ValueError."""
|
||||
from scribe.services.events import create_event
|
||||
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
|
||||
with pytest.raises(ValueError, match="at or after start_dt"):
|
||||
await create_event(
|
||||
user_id=1, title="Bad",
|
||||
start_dt=start, end_dt=end_before,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_preserves_duration_when_only_start_changes():
|
||||
"""Sliding semantics: when the user moves an event by changing only
|
||||
start_dt, the existing duration_minutes is preserved as-is. The new
|
||||
effective end_dt slides forward with the start. This is a behavioral
|
||||
upgrade vs. the old end_dt model, where moving start past the
|
||||
stored end made the event 'go backward in time'."""
|
||||
mock_event = _make_mock_event(duration_minutes=60) # start 10:00, end 11:00
|
||||
mock_session = _make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = mock_event
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("scribe.services.events.async_session") as mock_cls, \
|
||||
patch("scribe.services.events.asyncio.create_task"):
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.events import update_event
|
||||
# Move start to 12:00; effective end becomes 13:00 automatically.
|
||||
result = await update_event(
|
||||
user_id=1, event_id=1,
|
||||
start_dt=datetime(2026, 3, 25, 12, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
assert result is not None
|
||||
# duration_minutes was NOT touched; mock_event still has 60.
|
||||
assert mock_event.duration_minutes == 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_clearing_end_dt_clears_duration():
|
||||
"""Passing end_dt=None on update is the documented way to clear the
|
||||
end (turn a timed event into a point event). The service must
|
||||
translate that into duration_minutes=None, not leave the prior
|
||||
value in place."""
|
||||
mock_event = _make_mock_event(duration_minutes=60)
|
||||
mock_session = _make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = mock_event
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("scribe.services.events.async_session") as mock_cls, \
|
||||
patch("scribe.services.events.asyncio.create_task"):
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.events import update_event
|
||||
await update_event(user_id=1, event_id=1, end_dt=None)
|
||||
assert mock_event.duration_minutes is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_includes_point_event_in_window():
|
||||
"""A point event (duration_minutes=None) surfaces when its start
|
||||
is in the window. Replaces the prior 'corrupt end_dt' regression
|
||||
test — the duration model can't represent that state, but the
|
||||
same code path is exercised here for point events."""
|
||||
mock_event = _make_mock_event(duration_minutes=None)
|
||||
# Point event in the upcoming window
|
||||
mock_event.start_dt = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
mock_event.end_dt = None
|
||||
mock_event.to_dict.return_value = {
|
||||
"id": 1, "title": "Point",
|
||||
"start_dt": mock_event.start_dt.isoformat(),
|
||||
"end_dt": None,
|
||||
"duration_minutes": None,
|
||||
}
|
||||
mock_session = _make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [mock_event]
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("scribe.services.events.async_session") as mock_cls:
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.events import list_events
|
||||
results = await list_events(
|
||||
user_id=1,
|
||||
date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
|
||||
date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
|
||||
)
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_excludes_timed_event_that_already_ended():
|
||||
"""A timed event whose start + duration is before the window must
|
||||
NOT surface. Verifies the Python-side refinement actually works
|
||||
against the coarse SQL prefilter."""
|
||||
mock_event = _make_mock_event(duration_minutes=60)
|
||||
# Start 4/20 12:00, end 4/20 13:00; window is 4/29 → 5/27 — fully past.
|
||||
mock_event.start_dt = datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc)
|
||||
mock_event.end_dt = datetime(2026, 4, 20, 13, 0, tzinfo=timezone.utc)
|
||||
mock_event.to_dict.return_value = {
|
||||
"id": 1, "start_dt": mock_event.start_dt.isoformat(),
|
||||
"end_dt": mock_event.end_dt.isoformat(), "duration_minutes": 60,
|
||||
}
|
||||
mock_session = _make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [mock_event]
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("scribe.services.events.async_session") as mock_cls:
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.events import list_events
|
||||
results = await list_events(
|
||||
user_id=1,
|
||||
date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
|
||||
date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
|
||||
)
|
||||
assert results == []
|
||||
|
||||
|
||||
# test_tools_calendar_always_available removed in Phase 8 along with the
|
||||
# services/tools/ LLM-tool layer. Event CRUD is now exposed via MCP tools
|
||||
# (see tests/test_mcp_tool_events.py for coverage).
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Tests for typed-entity tools (person/place/list).
|
||||
|
||||
Focuses on the metadata-shape translations and the entity_meta merge logic,
|
||||
since that's where bugs would hide."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.mcp._context import _user_id_ctx
|
||||
from scribe.mcp.tools.entities import (
|
||||
list_persons, create_person, update_person,
|
||||
create_place, update_place,
|
||||
create_list, update_list,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_user():
|
||||
token = _user_id_ctx.set(7)
|
||||
yield
|
||||
_user_id_ctx.reset(token)
|
||||
|
||||
|
||||
def _fake_note(*, note_type="note", entity_meta=None, **overrides) -> MagicMock:
|
||||
n = MagicMock()
|
||||
n.note_type = note_type
|
||||
n.entity_meta = entity_meta
|
||||
base = {"id": 1, "title": "t", "note_type": note_type,
|
||||
"metadata": entity_meta or {}}
|
||||
base.update(overrides)
|
||||
n.to_dict.return_value = base
|
||||
return n
|
||||
|
||||
|
||||
# ─── list ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_persons_calls_knowledge_with_person_type():
|
||||
mock = AsyncMock(return_value=([{"id": 1, "title": "alice"}], 1))
|
||||
with patch("scribe.mcp.tools.entities.knowledge_svc.query_knowledge", mock):
|
||||
out = await list_persons(tag="work")
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert kwargs["note_type"] == "person"
|
||||
assert kwargs["tags"] == ["work"]
|
||||
assert out["total"] == 1
|
||||
assert "persons" in out # type-specific plural key
|
||||
|
||||
|
||||
# ─── create: metadata building ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_person_only_includes_provided_fields_in_meta():
|
||||
"""Empty-string fields must NOT pollute entity_meta."""
|
||||
fake = _fake_note(note_type="person")
|
||||
mock = AsyncMock(return_value=fake)
|
||||
with patch("scribe.mcp.tools.entities.notes_svc.create_note", mock):
|
||||
await create_person(name="Alice", email="a@x.com")
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert kwargs["title"] == "Alice"
|
||||
assert kwargs["note_type"] == "person"
|
||||
assert kwargs["entity_meta"] == {"email": "a@x.com"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_person_all_empty_meta_is_none():
|
||||
"""Service is called with entity_meta=None when no typed fields were given."""
|
||||
fake = _fake_note(note_type="person")
|
||||
mock = AsyncMock(return_value=fake)
|
||||
with patch("scribe.mcp.tools.entities.notes_svc.create_note", mock):
|
||||
await create_person(name="Bob")
|
||||
assert mock.call_args.kwargs["entity_meta"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_place_categories_into_meta():
|
||||
fake = _fake_note(note_type="place")
|
||||
mock = AsyncMock(return_value=fake)
|
||||
with patch("scribe.mcp.tools.entities.notes_svc.create_note", mock):
|
||||
await create_place(name="Cafe X", address="123 Main", category="coffee")
|
||||
meta = mock.call_args.kwargs["entity_meta"]
|
||||
assert meta == {"address": "123 Main", "category": "coffee"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_list_translates_strings_to_unchecked_items():
|
||||
fake = _fake_note(note_type="list")
|
||||
mock = AsyncMock(return_value=fake)
|
||||
with patch("scribe.mcp.tools.entities.notes_svc.create_note", mock):
|
||||
await create_list(name="shopping", items=["milk", "bread"])
|
||||
meta = mock.call_args.kwargs["entity_meta"]
|
||||
assert meta["list_items"] == [
|
||||
{"text": "milk", "checked": False},
|
||||
{"text": "bread", "checked": False},
|
||||
]
|
||||
|
||||
|
||||
# ─── update: meta merge ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_person_merges_meta_preserving_other_fields():
|
||||
"""Updating one field must NOT clobber the others stored in entity_meta."""
|
||||
existing = _fake_note(
|
||||
id=5, note_type="person",
|
||||
entity_meta={"email": "old@x.com", "phone": "555-1234"},
|
||||
)
|
||||
get_mock = AsyncMock(return_value=existing)
|
||||
updated = _fake_note(note_type="person")
|
||||
update_mock = AsyncMock(return_value=updated)
|
||||
with patch(
|
||||
"scribe.mcp.tools.entities.notes_svc.get_note", get_mock,
|
||||
), patch(
|
||||
"scribe.mcp.tools.entities.notes_svc.update_note", update_mock,
|
||||
):
|
||||
await update_person(person_id=5, email="new@x.com")
|
||||
new_meta = update_mock.call_args.kwargs["entity_meta"]
|
||||
assert new_meta == {"email": "new@x.com", "phone": "555-1234"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_person_no_typed_fields_keeps_meta_unchanged():
|
||||
existing = _fake_note(
|
||||
id=5, note_type="person",
|
||||
entity_meta={"email": "a@x.com"},
|
||||
)
|
||||
updated = _fake_note(note_type="person")
|
||||
with patch(
|
||||
"scribe.mcp.tools.entities.notes_svc.get_note",
|
||||
AsyncMock(return_value=existing),
|
||||
), patch(
|
||||
"scribe.mcp.tools.entities.notes_svc.update_note",
|
||||
AsyncMock(return_value=updated),
|
||||
) as update_mock:
|
||||
await update_person(person_id=5, name="New Name")
|
||||
# entity_meta unchanged, but still passed (service gets the full new dict)
|
||||
assert update_mock.call_args.kwargs["entity_meta"] == {"email": "a@x.com"}
|
||||
assert update_mock.call_args.kwargs["title"] == "New Name"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_person_rejects_wrong_type():
|
||||
"""Trying to update a 'place' as a 'person' must fail."""
|
||||
wrong_type = _fake_note(id=5, note_type="place")
|
||||
with patch(
|
||||
"scribe.mcp.tools.entities.notes_svc.get_note",
|
||||
AsyncMock(return_value=wrong_type),
|
||||
):
|
||||
with pytest.raises(ValueError, match="person 5 not found"):
|
||||
await update_person(person_id=5, email="x@x.com")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_list_items_empty_list_clears_items():
|
||||
"""items=[] clears all items; items=None leaves unchanged."""
|
||||
existing = _fake_note(
|
||||
id=5, note_type="list",
|
||||
entity_meta={"list_items": [{"text": "old", "checked": True}]},
|
||||
)
|
||||
updated = _fake_note(note_type="list")
|
||||
with patch(
|
||||
"scribe.mcp.tools.entities.notes_svc.get_note",
|
||||
AsyncMock(return_value=existing),
|
||||
), patch(
|
||||
"scribe.mcp.tools.entities.notes_svc.update_note",
|
||||
AsyncMock(return_value=updated),
|
||||
) as update_mock:
|
||||
await update_list(list_id=5, items=[])
|
||||
assert update_mock.call_args.kwargs["entity_meta"]["list_items"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_list_items_none_leaves_items_unchanged():
|
||||
existing = _fake_note(
|
||||
id=5, note_type="list",
|
||||
entity_meta={"list_items": [{"text": "keep", "checked": False}]},
|
||||
)
|
||||
updated = _fake_note(note_type="list")
|
||||
with patch(
|
||||
"scribe.mcp.tools.entities.notes_svc.get_note",
|
||||
AsyncMock(return_value=existing),
|
||||
), patch(
|
||||
"scribe.mcp.tools.entities.notes_svc.update_note",
|
||||
AsyncMock(return_value=updated),
|
||||
) as update_mock:
|
||||
await update_list(list_id=5, name="renamed")
|
||||
# Existing items intact in the merged meta
|
||||
assert update_mock.call_args.kwargs["entity_meta"]["list_items"] == [
|
||||
{"text": "keep", "checked": False},
|
||||
]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Tests for fable_*_event tools."""
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.mcp._context import _user_id_ctx
|
||||
from scribe.mcp.tools.events import (
|
||||
list_events, create_event, get_event,
|
||||
update_event, delete_event,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_user():
|
||||
token = _user_id_ctx.set(7)
|
||||
yield
|
||||
_user_id_ctx.reset(token)
|
||||
|
||||
|
||||
def _fake_event(**overrides) -> MagicMock:
|
||||
e = MagicMock()
|
||||
base = {
|
||||
"id": 1, "title": "ev", "start_dt": "2026-06-01T10:00:00",
|
||||
"duration_minutes": 30, "all_day": False,
|
||||
"location": "", "description": "",
|
||||
}
|
||||
base.update(overrides)
|
||||
e.to_dict.return_value = base
|
||||
return e
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_passes_timezone_aware_range():
|
||||
"""Range must be tz-aware (UTC) and date_to inclusive at end-of-day —
|
||||
Event.start_dt is tz-aware in the DB; naive comparisons raise TypeError."""
|
||||
from datetime import timezone
|
||||
mock = AsyncMock(return_value=[
|
||||
{"id": 1, "title": "morning standup"},
|
||||
])
|
||||
with patch("scribe.mcp.tools.events.events_svc.list_events", mock):
|
||||
out = await list_events(date_from="2026-06-01", date_to="2026-06-08")
|
||||
args, _ = mock.call_args
|
||||
assert args[0] == 7 # user_id
|
||||
assert args[1] == datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
# date_to is end-of-day inclusive → start of 2026-06-09 (24h past start of 2026-06-08)
|
||||
assert args[2] == datetime(2026, 6, 9, tzinfo=timezone.utc)
|
||||
assert out["total"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_combines_date_and_time():
|
||||
e = _fake_event()
|
||||
mock = AsyncMock(return_value=e)
|
||||
with patch("scribe.mcp.tools.events.events_svc.create_event", mock):
|
||||
await create_event(
|
||||
title="standup", start_date="2026-06-01", start_time="09:30",
|
||||
duration_minutes=15,
|
||||
)
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert kwargs["start_dt"] == datetime(2026, 6, 1, 9, 30)
|
||||
assert kwargs["duration_minutes"] == 15
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_zero_duration_means_point_event():
|
||||
"""duration_minutes=0 must map to None at the service layer (NULL = point)."""
|
||||
e = _fake_event()
|
||||
mock = AsyncMock(return_value=e)
|
||||
with patch("scribe.mcp.tools.events.events_svc.create_event", mock):
|
||||
await create_event(title="x", start_date="2026-06-01")
|
||||
assert mock.call_args.kwargs["duration_minutes"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_event_raises_when_not_found():
|
||||
with patch(
|
||||
"scribe.mcp.tools.events.events_svc.get_event",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
with pytest.raises(ValueError, match="event 999 not found"):
|
||||
await get_event(event_id=999)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_only_sends_non_default_fields():
|
||||
e = _fake_event()
|
||||
mock = AsyncMock(return_value=e)
|
||||
with patch("scribe.mcp.tools.events.events_svc.update_event", mock):
|
||||
await update_event(event_id=1, title="new title")
|
||||
args, kwargs = mock.call_args
|
||||
assert args == (7, 1)
|
||||
assert kwargs == {"title": "new title"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_duration_minus_one_means_unchanged():
|
||||
e = _fake_event()
|
||||
mock = AsyncMock(return_value=e)
|
||||
with patch("scribe.mcp.tools.events.events_svc.update_event", mock):
|
||||
await update_event(event_id=1, duration_minutes=-1)
|
||||
assert "duration_minutes" not in mock.call_args.kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_duration_zero_clears_to_point():
|
||||
"""duration_minutes=0 means "set to point event" (NULL)."""
|
||||
e = _fake_event()
|
||||
mock = AsyncMock(return_value=e)
|
||||
with patch("scribe.mcp.tools.events.events_svc.update_event", mock):
|
||||
await update_event(event_id=1, duration_minutes=0)
|
||||
assert mock.call_args.kwargs["duration_minutes"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_requires_both_date_and_time_to_move():
|
||||
e = _fake_event()
|
||||
mock = AsyncMock(return_value=e)
|
||||
with patch("scribe.mcp.tools.events.events_svc.update_event", mock):
|
||||
await update_event(event_id=1, start_date="2026-06-02")
|
||||
# Only start_date, no start_time → start_dt NOT in fields
|
||||
assert "start_dt" not in mock.call_args.kwargs
|
||||
|
||||
mock.reset_mock()
|
||||
await update_event(
|
||||
event_id=1, start_date="2026-06-02", start_time="11:00",
|
||||
)
|
||||
assert mock.call_args.kwargs["start_dt"] == datetime(2026, 6, 2, 11)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_raises_when_not_found():
|
||||
with patch(
|
||||
"scribe.mcp.tools.events.events_svc.update_event",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
with pytest.raises(ValueError, match="event 999 not found"):
|
||||
await update_event(event_id=999, title="x")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_event_soft_deletes_and_returns_batch():
|
||||
with patch(
|
||||
"scribe.mcp.tools.events.trash_svc.delete",
|
||||
AsyncMock(return_value="batch-1"),
|
||||
):
|
||||
result = await delete_event(event_id=7)
|
||||
assert result["deleted_batch_id"] == "batch-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_event_raises_when_not_found():
|
||||
with patch(
|
||||
"scribe.mcp.tools.events.trash_svc.delete",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
with pytest.raises(ValueError, match="event 999 not found"):
|
||||
await delete_event(event_id=999)
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Unit tests for the v4 backup export contract.
|
||||
"""Unit tests for the v3 backup export contract.
|
||||
|
||||
CI runs pytest with no database, so these cover the parts that don't need one:
|
||||
the version/coverage constants, the pure join-table row helpers, and the export
|
||||
dict shape (via a mocked session). Full FK-remapping round-trip is exercised
|
||||
manually against a real DB (export a backup, confirm rulebooks appear).
|
||||
manually against a real DB (export a backup, confirm rulebooks/events appear).
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
@@ -13,8 +13,8 @@ import pytest
|
||||
from scribe.services import backup
|
||||
|
||||
|
||||
def test_backup_version_is_v4():
|
||||
assert backup.BACKUP_VERSION == 4
|
||||
def test_backup_version_is_v3():
|
||||
assert backup.BACKUP_VERSION == 3
|
||||
|
||||
|
||||
def test_not_included_lists_the_known_gaps():
|
||||
@@ -61,12 +61,12 @@ async def test_export_full_backup_contains_v3_sections():
|
||||
with patch("scribe.services.backup.async_session", lambda: _CM()):
|
||||
out = await backup.export_full_backup()
|
||||
|
||||
assert out["version"] == 4
|
||||
assert out["version"] == 3
|
||||
assert out["scope"] == "full"
|
||||
assert "api_keys" in out["_not_included"]
|
||||
# The sections v2 silently dropped must now be present (empty here).
|
||||
for key in ("rulebooks", "rulebook_topics", "rules",
|
||||
"rulebook_subscriptions", "rule_suppressions",
|
||||
"topic_suppressions"):
|
||||
"topic_suppressions", "events"):
|
||||
assert key in out, f"missing v3 section: {key}"
|
||||
assert out[key] == []
|
||||
|
||||
@@ -42,12 +42,14 @@ async def test_build_dashboard_composes_sections():
|
||||
import scribe.services.dashboard as dash
|
||||
with patch.object(dash, "_active_projects", AsyncMock(return_value=["P"])), \
|
||||
patch.object(dash, "_recently_completed", AsyncMock(return_value=["done"])), \
|
||||
patch.object(dash, "_upcoming_events", AsyncMock(return_value=["evt"])), \
|
||||
patch.object(dash, "_open_issues", AsyncMock(return_value=["iss"])), \
|
||||
patch.object(dash, "_week_stats", AsyncMock(return_value={"open_total": 4})):
|
||||
out = await dash.build_dashboard(user_id=1)
|
||||
assert out == {
|
||||
"active_projects": ["P"],
|
||||
"recently_completed": ["done"],
|
||||
"upcoming_events": ["evt"],
|
||||
"open_issues": ["iss"],
|
||||
"week_stats": {"open_total": 4},
|
||||
}
|
||||
@@ -58,6 +60,7 @@ async def test_build_dashboard_isolates_failing_section():
|
||||
import scribe.services.dashboard as dash
|
||||
with patch.object(dash, "_active_projects", AsyncMock(side_effect=RuntimeError("db down"))), \
|
||||
patch.object(dash, "_recently_completed", AsyncMock(return_value=["done"])), \
|
||||
patch.object(dash, "_upcoming_events", AsyncMock(return_value=[])), \
|
||||
patch.object(dash, "_open_issues", AsyncMock(return_value=[])), \
|
||||
patch.object(dash, "_week_stats", AsyncMock(return_value={})):
|
||||
out = await dash.build_dashboard(user_id=1)
|
||||
|
||||
@@ -39,7 +39,7 @@ async def test_counts_include_process_in_facet_and_total():
|
||||
|
||||
assert counts["process"] == 2
|
||||
# facet keys all present (setdefault)
|
||||
for key in ("note", "task", "plan", "process"):
|
||||
for key in ("note", "person", "place", "list", "task", "plan", "process"):
|
||||
assert key in counts
|
||||
# total = note(3) + task(1) + process(2)
|
||||
# total = note(3) + person(0) + place(0) + list(0) + task(1) + process(2)
|
||||
assert counts["total"] == 6
|
||||
|
||||
@@ -97,14 +97,14 @@ def _rowcount_result(n):
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_clears_batch_across_all_models():
|
||||
session = _make_mock_session()
|
||||
# 6 soft-deletable models, each returns a rowcount
|
||||
session.execute = AsyncMock(side_effect=[_rowcount_result(i) for i in [2, 0, 1, 1, 0, 0]])
|
||||
# 7 models, each returns a rowcount
|
||||
session.execute = AsyncMock(side_effect=[_rowcount_result(i) for i in [2, 0, 1, 1, 0, 0, 0]])
|
||||
with patch("scribe.services.trash.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from scribe.services.trash import restore
|
||||
n = await restore(user_id=1, batch_id="abc")
|
||||
assert n == 4
|
||||
assert session.execute.await_count == 6
|
||||
assert session.execute.await_count == 7
|
||||
assert session.commit.called
|
||||
|
||||
|
||||
@@ -123,13 +123,13 @@ async def test_purge_expired_skips_when_retention_zero():
|
||||
@pytest.mark.asyncio
|
||||
async def test_purge_expired_deletes_across_models_when_positive():
|
||||
session = _make_mock_session()
|
||||
session.execute = AsyncMock(side_effect=[_rowcount_result(1) for _ in range(6)])
|
||||
session.execute = AsyncMock(side_effect=[_rowcount_result(1) for _ in range(7)])
|
||||
with patch("scribe.services.trash.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from scribe.services.trash import purge_expired
|
||||
n = await purge_expired(1, 90)
|
||||
assert n == 6
|
||||
assert session.execute.await_count == 6
|
||||
assert n == 7
|
||||
assert session.execute.await_count == 7
|
||||
|
||||
|
||||
def test_owner_clause_scopes_every_model():
|
||||
|
||||
@@ -5,6 +5,7 @@ compiled SQL of every statement passed to execute, then assert the
|
||||
'deleted_at IS NULL' filter is present. No real DB.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -65,6 +66,20 @@ async def test_list_projects_excludes_trashed():
|
||||
assert _has_filter(cap)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_excludes_trashed():
|
||||
cap: list[str] = []
|
||||
with patch("scribe.services.events.async_session") as cls:
|
||||
cls.return_value = _capturing_session(cap)
|
||||
from scribe.services.events import list_events
|
||||
await list_events(
|
||||
1,
|
||||
datetime(2026, 5, 1, tzinfo=timezone.utc),
|
||||
datetime(2026, 5, 31, tzinfo=timezone.utc),
|
||||
)
|
||||
assert _has_filter(cap)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_knowledge_excludes_trashed():
|
||||
cap: list[str] = []
|
||||
|
||||
Reference in New Issue
Block a user