Compare commits

..

5 Commits

Author SHA1 Message Date
bvandeusen 03772ff424 Merge pull request 'fix(plugin): bump to 0.1.10 to ship the auto-inject hook' (#75) from dev into main 2026-06-22 22:08:25 -04:00
bvandeusen 058e8794af Merge pull request 'KB injection tuning: pgvector substrate + retrieval telemetry + title-first auto-inject' (#74) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 20s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 13s
2026-06-22 20:56:15 -04:00
bvandeusen f8c58a7f0f Merge pull request 'feat(mcp): S5 — issue-kind guidance across all instruction surfaces' (#73) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 14s
2026-06-14 23:32:04 -04:00
bvandeusen 6cdac307af Merge pull request 'DB maintenance + health observability + Postgres integration CI lane' (#72) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 14s
2026-06-14 20:54:34 -04:00
bvandeusen d324205450 Merge pull request 'Release: Issues+Systems, milestone-as-plan, plugin reliability/skills/dedup, compaction hygiene' (#71) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 14s
2026-06-14 15:51:44 -04:00
54 changed files with 6062 additions and 248 deletions
+2 -2
View File
@@ -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))
+2 -1
View File
@@ -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 |
+7
View File
@@ -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>
+66
View File
@@ -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 {
+2
View File
@@ -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>
+677
View File
@@ -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>
+277
View File
@@ -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>&mdash;</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>
+5
View File
@@ -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",
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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;
}
+763
View File
@@ -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>
+27 -1
View File
@@ -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; }
+265 -13
View File
@@ -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;
+361 -9
View File
@@ -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. MonFri 9am5pm" @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">&times;</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; }
+134
View File
@@ -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 */
+2 -2
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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.
+3 -12
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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,
)
+8 -2
View File
@@ -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",
+3 -1
View File
@@ -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)
+245
View File
@@ -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)
+171
View File
@@ -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)
+19 -5
View File
@@ -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)}
+1
View File
@@ -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
+79
View File
@@ -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,
}
+11 -3
View File
@@ -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(),
}
+142
View File
@@ -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)
+3 -3
View File
@@ -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)
+7
View File
@@ -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")
+75 -1
View File
@@ -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():
+80 -9
View File
@@ -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)
+770
View File
@@ -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}
+247
View File
@@ -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)
+15 -2
View File
@@ -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:
+198
View File
@@ -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")
+477
View File
@@ -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)
+75 -6
View File
@@ -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)
+2
View File
@@ -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")
+24 -3
View File
@@ -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",
}
+26
View File
@@ -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
+67
View File
@@ -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
+326
View File
@@ -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).
+191
View File
@@ -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},
]
+158
View File
@@ -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)
+6 -6
View File
@@ -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] == []
+3
View File
@@ -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)
+2 -2
View File
@@ -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
+6 -6
View File
@@ -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():
+15
View File
@@ -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] = []