Merge pull request 'Release v26.04.10.1' (#28) from dev into main

This commit was merged in pull request #28.
This commit is contained in:
2026-04-10 15:05:15 +00:00
12 changed files with 207 additions and 77 deletions
+3 -3
View File
@@ -208,7 +208,7 @@ router.afterEach(() => {
} }
.nav-link { .nav-link {
color: var(--color-text-muted); color: var(--color-text-secondary);
text-decoration: none; text-decoration: none;
font-size: 0.82rem; font-size: 0.82rem;
padding: 0.3rem 0.75rem; padding: 0.3rem 0.75rem;
@@ -216,11 +216,11 @@ router.afterEach(() => {
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
} }
.nav-link:hover { .nav-link:hover {
color: var(--color-text-secondary); color: var(--color-text);
background: var(--color-primary-tint); background: var(--color-primary-tint);
} }
.nav-link.router-link-active { .nav-link.router-link-active {
color: #c4b5fd; color: var(--color-primary-solid);
font-weight: 600; font-weight: 600;
background: rgba(124, 58, 237, 0.25); background: rgba(124, 58, 237, 0.25);
box-shadow: 0 0 16px rgba(124, 58, 237, 0.3); box-shadow: 0 0 16px rgba(124, 58, 237, 0.3);
+4 -31
View File
@@ -74,6 +74,10 @@ let currentWeatherTimer: ReturnType<typeof setInterval> | null = null
async function loadCurrentConditions() { async function loadCurrentConditions() {
try { try {
currentConditions.value = await apiGet<CurrentConditions>('/api/briefing/weather/current') currentConditions.value = await apiGet<CurrentConditions>('/api/briefing/weather/current')
// Patch the live temperature into the WeatherCard so it stays fresh
if (currentConditions.value?.temperature != null && weatherData.value.length > 0) {
weatherData.value[0] = { ...weatherData.value[0], current_temp: currentConditions.value.temperature }
}
} catch { /* silent — endpoint may not have locations configured */ } } catch { /* silent — endpoint may not have locations configured */ }
} }
@@ -260,14 +264,6 @@ onMounted(async () => {
<!-- Left column: Weather --> <!-- Left column: Weather -->
<div class="briefing-left"> <div class="briefing-left">
<div class="panel-label">Weather</div> <div class="panel-label">Weather</div>
<!-- Current conditions (live) -->
<div v-if="currentConditions && currentConditions.temperature != null" class="current-conditions">
<div class="cc-temp">{{ currentConditions.temperature }}°{{ currentConditions.temp_unit }}</div>
<div class="cc-desc">{{ currentConditions.description }}</div>
<div v-if="currentConditions.precip_next_3h?.some((p: number) => p > 0)" class="cc-precip">
Rain next 3h: {{ currentConditions.precip_next_3h.map((p: number) => `${p}%`).join(', ') }}
</div>
</div>
<!-- Forecast card --> <!-- Forecast card -->
<template v-if="weatherData.length"> <template v-if="weatherData.length">
<WeatherCard <WeatherCard
@@ -492,29 +488,6 @@ onMounted(async () => {
} }
/* ── Current conditions (live) ─────────────────────────── */ /* ── Current conditions (live) ─────────────────────────── */
.current-conditions {
padding: 12px 16px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
margin-bottom: 10px;
}
.cc-temp {
font-size: 1.8rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.1;
}
.cc-desc {
font-size: 0.85rem;
color: var(--color-text-secondary);
margin-top: 2px;
}
.cc-precip {
font-size: 0.78rem;
color: var(--color-accent-warm);
margin-top: 6px;
}
.panel-empty { .panel-empty {
font-size: 0.82rem; font-size: 0.82rem;
+2 -2
View File
@@ -402,7 +402,7 @@ async function confirmDelete() {
await store.deleteNote(noteId.value); await store.deleteNote(noteId.value);
dirty.value = false; dirty.value = false;
toast.show("Note deleted"); toast.show("Note deleted");
router.push("/notes"); router.push(projectId.value ? `/projects/${projectId.value}` : "/");
} catch { } catch {
toast.show("Failed to delete note", "error"); toast.show("Failed to delete note", "error");
} }
@@ -445,7 +445,7 @@ onUnmounted(() => assist.clearSelection());
<main class="editor-page note-editor-page"> <main class="editor-page note-editor-page">
<div class="editor-header"> <div class="editor-header">
<div class="toolbar"> <div class="toolbar">
<router-link to="/notes" class="btn-back"> Notes</router-link> <router-link :to="projectId ? `/projects/${projectId}` : '/'" class="btn-back"> {{ projectId ? 'Project' : 'Knowledge' }}</router-link>
<button class="btn-save" @click="save" :disabled="saving"> <button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }} {{ saving ? "Saving..." : "Save" }}
</button> </button>
+52 -3
View File
@@ -20,7 +20,7 @@ interface Project {
title: string; title: string;
description: string | null; description: string | null;
goal: string | null; goal: string | null;
status: "active" | "completed" | "archived"; status: "active" | "paused" | "completed" | "archived";
color: string | null; color: string | null;
auto_summary: string | null; auto_summary: string | null;
permission?: string; permission?: string;
@@ -40,7 +40,7 @@ const toast = useToastStore();
const projects = ref<Project[]>([]); const projects = ref<Project[]>([]);
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const activeTab = ref<"all" | "active" | "completed" | "archived">("all"); const activeTab = ref<"all" | "active" | "paused" | "completed" | "archived">("all");
// New project modal // New project modal
const showNewProjectModal = ref(false); const showNewProjectModal = ref(false);
@@ -103,6 +103,7 @@ async function createProject() {
function statusLabel(status: Project["status"]): string { function statusLabel(status: Project["status"]): string {
if (status === "active") return "Active"; if (status === "active") return "Active";
if (status === "paused") return "Paused";
if (status === "completed") return "Completed"; if (status === "completed") return "Completed";
if (status === "archived") return "Archived"; if (status === "archived") return "Archived";
return status; return status;
@@ -112,6 +113,14 @@ function truncate(text: string | null, max = 120): string {
if (!text) return ""; if (!text) return "";
return text.length > max ? text.slice(0, max) + "..." : text; return text.length > max ? text.slice(0, max) + "..." : text;
} }
function overallPct(project: Project): { total: number; pct: number } {
const counts = project.summary?.task_counts;
if (!counts) return { total: 0, pct: 0 };
const total = (counts.todo ?? 0) + (counts.in_progress ?? 0) + (counts.done ?? 0);
const pct = total > 0 ? Math.round((counts.done ?? 0) / total * 100) : 0;
return { total, pct };
}
</script> </script>
<template> <template>
@@ -124,7 +133,7 @@ function truncate(text: string | null, max = 120): string {
<!-- Filter tabs --> <!-- Filter tabs -->
<div class="filter-tabs"> <div class="filter-tabs">
<button <button
v-for="tab in ['all', 'active', 'completed', 'archived'] as const" v-for="tab in ['all', 'active', 'paused', 'completed', 'archived'] as const"
:key="tab" :key="tab"
:class="['tab-btn', { active: activeTab === tab }]" :class="['tab-btn', { active: activeTab === tab }]"
@click="activeTab = tab" @click="activeTab = tab"
@@ -163,6 +172,18 @@ function truncate(text: string | null, max = 120): string {
</p> </p>
<p v-if="project.description" class="project-desc">{{ truncate(project.description) }}</p> <p v-if="project.description" class="project-desc">{{ truncate(project.description) }}</p>
<!-- Overall completion bar -->
<div
v-if="overallPct(project).total > 0"
class="overall-bar-row"
:title="`${project.summary?.task_counts.done ?? 0} of ${overallPct(project).total} tasks complete`"
>
<div class="overall-bar-track">
<div class="overall-bar-fill" :style="{ width: overallPct(project).pct + '%' }"></div>
</div>
<span class="overall-bar-pct">{{ overallPct(project).pct }}%</span>
</div>
<!-- Milestone progress bars --> <!-- Milestone progress bars -->
<div <div
v-if="project.summary?.milestone_summary?.length" v-if="project.summary?.milestone_summary?.length"
@@ -416,6 +437,34 @@ function truncate(text: string | null, max = 120): string {
line-height: 1.45; line-height: 1.45;
} }
.overall-bar-row {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
margin-top: 0.1rem;
}
.overall-bar-track {
flex: 1;
height: 6px;
background: var(--color-border);
border-radius: 999px;
overflow: hidden;
}
.overall-bar-fill {
height: 100%;
border-radius: 999px;
background: var(--color-primary);
transition: width 0.3s ease;
}
.overall-bar-pct {
color: var(--color-text-secondary);
flex-shrink: 0;
min-width: 2.5rem;
text-align: right;
font-weight: 600;
}
.milestone-bars { .milestone-bars {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+13 -1
View File
@@ -23,7 +23,7 @@ interface Project {
title: string; title: string;
description: string | null; description: string | null;
goal: string | null; goal: string | null;
status: "active" | "completed" | "archived"; status: "active" | "paused" | "completed" | "archived";
color: string | null; color: string | null;
auto_summary: string | null; auto_summary: string | null;
permission?: string; permission?: string;
@@ -118,6 +118,14 @@ function toggleMilestoneCollapse(id: number) {
} }
} }
function autoCollapseCompleted(msList: Milestone[]) {
for (const ms of msList) {
if (ms.total > 0 && ms.completed === ms.total) {
collapsedMilestones.value.add(ms.id);
}
}
}
async function loadProject() { async function loadProject() {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
@@ -130,6 +138,7 @@ async function loadProject() {
editStatus.value = data.status; editStatus.value = data.status;
editDirty.value = false; editDirty.value = false;
milestones.value = data.summary?.milestone_summary ?? []; milestones.value = data.summary?.milestone_summary ?? [];
autoCollapseCompleted(milestones.value);
} catch { } catch {
error.value = "Failed to load project."; error.value = "Failed to load project.";
} finally { } finally {
@@ -143,6 +152,7 @@ async function loadMilestones() {
`/api/projects/${projectId.value}/milestones` `/api/projects/${projectId.value}/milestones`
); );
milestones.value = data.milestones; milestones.value = data.milestones;
autoCollapseCompleted(milestones.value);
} catch { } catch {
// silent // silent
} }
@@ -395,6 +405,7 @@ async function confirmDelete() {
<label class="edit-label">Status</label> <label class="edit-label">Status</label>
<select v-model="editStatus" class="edit-select"> <select v-model="editStatus" class="edit-select">
<option value="active">Active</option> <option value="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option> <option value="completed">Completed</option>
<option value="archived">Archived</option> <option value="archived">Archived</option>
</select> </select>
@@ -752,6 +763,7 @@ async function confirmDelete() {
flex-shrink: 0; flex-shrink: 0;
} }
.status-active { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent); } .status-active { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent); }
.status-paused { background: color-mix(in srgb, var(--color-accent-warm) 14%, transparent); color: var(--color-accent-warm); border: 1px solid color-mix(in srgb, var(--color-accent-warm) 30%, transparent); }
.status-completed { background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary); border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent); } .status-completed { background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary); border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent); }
.status-archived { background: color-mix(in srgb, var(--color-text-muted) 14%, transparent); color: var(--color-text-muted); border: 1px solid color-mix(in srgb, var(--color-text-muted) 30%, transparent); } .status-archived { background: color-mix(in srgb, var(--color-text-muted) 14%, transparent); color: var(--color-text-muted); border: 1px solid color-mix(in srgb, var(--color-text-muted) 30%, transparent); }
+2 -2
View File
@@ -362,7 +362,7 @@ async function confirmDelete() {
await store.deleteTask(taskId.value); await store.deleteTask(taskId.value);
dirty.value = false; dirty.value = false;
toast.show("Task deleted"); toast.show("Task deleted");
router.push("/tasks"); router.push(projectId.value ? `/projects/${projectId.value}` : "/");
} catch { } catch {
toast.show("Failed to delete task", "error"); toast.show("Failed to delete task", "error");
} }
@@ -410,7 +410,7 @@ useEditorGuards(dirty, save);
<main class="editor-page task-editor-page"> <main class="editor-page task-editor-page">
<div class="editor-header"> <div class="editor-header">
<div class="toolbar"> <div class="toolbar">
<router-link to="/tasks" class="btn-back"> Tasks</router-link> <router-link :to="projectId ? `/projects/${projectId}` : '/'" class="btn-back"> {{ projectId ? 'Project' : 'Knowledge' }}</router-link>
<button class="btn-save" @click="save" :disabled="saving"> <button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }} {{ saving ? "Saving..." : "Save" }}
</button> </button>
+3 -3
View File
@@ -52,7 +52,7 @@ async def create_project_route():
if not data.get("title"): if not data.get("title"):
return jsonify({"error": "title is required"}), 400 return jsonify({"error": "title is required"}), 400
status = data.get("status", "active") status = data.get("status", "active")
if status not in ("active", "completed", "archived"): if status not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400 return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
project = await create_project( project = await create_project(
uid, uid,
@@ -88,8 +88,8 @@ async def update_project_route(project_id: int):
uid = get_current_user_id() uid = get_current_user_id()
data = await request.get_json() data = await request.get_json()
allowed = {"title", "description", "goal", "status", "color"} allowed = {"title", "description", "goal", "status", "color"}
fields = {k: v for k, v in data.items() if k in allowed} fields = {k: (v if v is not None else "") for k, v in data.items() if k in allowed}
if "status" in fields and fields["status"] not in ("active", "completed", "archived"): if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400 return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
project = await update_project(uid, project_id, **fields) project = await update_project(uid, project_id, **fields)
if project is None: if project is None:
@@ -13,7 +13,7 @@ functions wrapped with asyncio.run_coroutine_threadsafe().
import asyncio import asyncio
import logging import logging
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
@@ -162,6 +162,35 @@ def update_user_schedule(user_id: int, config: dict, tz_override: str | None = N
# ── Job execution ───────────────────────────────────────────────────────────── # ── Job execution ─────────────────────────────────────────────────────────────
async def _auto_pause_stale_projects(user_id: int) -> list[str]:
"""Pause active projects with no note/task activity in 14+ days. Returns paused project titles."""
from sqlalchemy import select as _sel, func as _func
from fabledassistant.models.project import Project
from fabledassistant.models.note import Note
from fabledassistant.models import async_session as _session
paused: list[str] = []
threshold = datetime.now(timezone.utc) - timedelta(days=14)
try:
async with _session() as session:
projects = (await session.execute(
_sel(Project).where(Project.user_id == user_id, Project.status == "active")
)).scalars().all()
for p in projects:
latest = (await session.execute(
_sel(_func.max(Note.updated_at)).where(Note.project_id == p.id)
)).scalar()
if latest is None or latest < threshold:
p.status = "paused"
paused.append(p.title or "Untitled")
if paused:
await session.commit()
logger.info("Auto-paused %d stale projects for user %d: %s", len(paused), user_id, paused)
except Exception:
logger.debug("Auto-pause check failed for user %d", user_id, exc_info=True)
return paused
async def _run_slot_for_user(user_id: int, slot: str) -> None: async def _run_slot_for_user(user_id: int, slot: str) -> None:
"""Execute one slot job for one user.""" """Execute one slot job for one user."""
from fabledassistant.services.briefing_conversations import ( from fabledassistant.services.briefing_conversations import (
@@ -192,6 +221,9 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL) model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
if slot == "compilation": if slot == "compilation":
# Auto-pause stale projects before compiling the briefing
await _auto_pause_stale_projects(user_id)
# Refresh external data first # Refresh external data first
try: try:
import json import json
+19 -13
View File
@@ -40,29 +40,35 @@ _EMAIL_LOGO_SVG = (
def _email_html(title: str, body: str) -> str: def _email_html(title: str, body: str) -> str:
"""Wrap email body content in the standard Fabled Assistant template.""" """Wrap email body content in the standard Fabled Assistant template."""
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html>
<html> <html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head> <head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"> <meta charset="utf-8">
<div style="padding:32px 16px;"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
</head>
<body style="margin:0;padding:0;background:#f5f3ff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<div style="padding:36px 16px 48px;">
<div style="max-width:520px;margin:0 auto;"> <div style="max-width:520px;margin:0 auto;">
<div style="background:#ffffff;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
<!-- Card -->
<div style="background:#ffffff;border:1px solid #ddd6fe;border-radius:14px;overflow:hidden;box-shadow:0 4px 24px rgba(109,40,217,0.08);">
<!-- Header --> <!-- Header -->
<div style="background:#6366f1;padding:20px 24px;text-align:center;"> <div style="background:linear-gradient(135deg,#7c3aed 0%,#6d28d9 100%);padding:24px 28px;text-align:center;">
<div style="margin-bottom:6px;"> <div style="margin-bottom:8px;">
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:17px;font-weight:600;letter-spacing:0.01em;">Fabled Assistant</span> {_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:18px;font-weight:700;letter-spacing:0.01em;">Fabled Assistant</span>
</div> </div>
<p style="margin:0;color:#c7d2fe;font-size:13px;">{title}</p> <p style="margin:0;color:#ede9fe;font-size:13px;letter-spacing:0.03em;">{title}</p>
</div> </div>
<!-- Body --> <!-- Body -->
<div style="padding:28px 24px;"> <div style="padding:32px 28px;color:#1e1b4b;">
{body} {body}
</div> </div>
<!-- Footer --> <!-- Footer -->
<div style="border-top:1px solid #e5e7eb;padding:14px 24px;text-align:center;background:#f9fafb;"> <div style="border-top:1px solid #ede9fe;padding:16px 28px;text-align:center;background:#faf5ff;">
<p style="margin:0;color:#9ca3af;font-size:12px;">This email was sent by your Fabled Assistant instance.</p> <p style="margin:0;color:#a78bfa;font-size:12px;">Sent by your Fabled Assistant instance.</p>
</div> </div>
</div> </div>
@@ -169,7 +175,7 @@ async def send_email(to: str, subject: str, html_body: str) -> None:
async def send_test_email(to: str) -> None: async def send_test_email(to: str) -> None:
"""Send a branded test email.""" """Send a branded test email."""
body = """ body = """
<p style="margin:0 0 12px;color:#111827;font-size:15px;font-weight:600;">SMTP is configured correctly</p> <p style="margin:0 0 12px;color:#1e1b4b;font-size:15px;font-weight:600;">SMTP is configured correctly</p>
<p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Assistant instance can send email notifications.</p> <p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Assistant instance can send email notifications.</p>
""" """
await send_email(to, "Fabled Assistant - Test Email", _email_html("Test Email", body)) await send_email(to, "Fabled Assistant - Test Email", _email_html("Test Email", body))
+56 -16
View File
@@ -122,34 +122,74 @@ async def _semantic_knowledge_search(
limit: int, limit: int,
offset: int, offset: int,
) -> tuple[list[dict], int]: ) -> tuple[list[dict], int]:
"""Semantic search over knowledge objects, with SQL filters applied post-rank.""" """Hybrid search: keyword matches first (title/body ILIKE), then semantic results.
Exact keyword matches always rank above semantic-only matches so that
searching for a name like "Weston" surfaces the note with that title
before conceptually related notes.
"""
# 1. Keyword search — title and body ILIKE
keyword_notes: list[Note] = []
try:
async with async_session() as session:
pattern = f"%{q}%"
base = (
select(Note)
.where(Note.user_id == user_id)
.where(Note.title.ilike(pattern) | Note.body.ilike(pattern))
)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
for tag in tags:
base = base.where(Note.tags.contains([tag]))
# Title matches first, then body-only matches, newest first within each
base = base.order_by(
Note.title.ilike(pattern).desc(),
Note.updated_at.desc(),
).limit(limit * 2)
keyword_notes = list((await session.execute(base)).scalars().all())
except Exception:
logger.warning("Keyword search failed", exc_info=True)
# 2. Semantic search — conceptual similarity
semantic_notes: list[Note] = []
try: try:
from fabledassistant.services.embeddings import semantic_search_notes from fabledassistant.services.embeddings import semantic_search_notes
# Fetch a larger candidate set to allow for filtering
is_task_filter = True if note_type == "task" else (False if note_type else None) is_task_filter = True if note_type == "task" else (False if note_type else None)
candidates = await semantic_search_notes( candidates = await semantic_search_notes(
user_id=user_id, user_id=user_id,
query=q, query=q,
limit=min(200, limit * 8), limit=min(200, limit * 4),
threshold=0.3, threshold=0.3,
is_task=is_task_filter, is_task=is_task_filter,
) )
for _score, note in candidates:
if note_type == "task" and not note.is_task:
continue
elif note_type and note_type != "task" and note.entity_type != note_type:
continue
if tags and not all(t in (note.tags or []) for t in tags):
continue
semantic_notes.append(note)
except Exception: except Exception:
logger.warning("Semantic search unavailable, falling back to SQL", exc_info=True) logger.warning("Semantic search unavailable, using keyword results only", exc_info=True)
return await query_knowledge(user_id, note_type, tags, "modified", None, limit, offset)
results = [] # 3. Merge — keyword matches first, then semantic (deduplicated)
for _score, note in candidates: seen_ids: set[int] = set()
if note_type == "task" and not note.is_task: merged: list[Note] = []
continue for note in keyword_notes:
elif note_type and note_type != "task" and note.entity_type != note_type: if note.id not in seen_ids:
continue seen_ids.add(note.id)
if tags and not all(t in (note.tags or []) for t in tags): merged.append(note)
continue for note in semantic_notes:
results.append(note) if note.id not in seen_ids:
seen_ids.add(note.id)
merged.append(note)
total = len(results) total = len(merged)
page_items = results[offset: offset + limit] page_items = merged[offset: offset + limit]
return [_note_to_item(n) for n in page_items], total return [_note_to_item(n) for n in page_items], total
+18
View File
@@ -22,6 +22,22 @@ def _normalize_tags(tags: list[str]) -> list[str]:
return out return out
async def _maybe_reactivate_project(project_id: int) -> None:
"""If a project is paused, reactivate it — activity indicates resumed work."""
from fabledassistant.models.project import Project
try:
async with async_session() as session:
project = (await session.execute(
select(Project).where(Project.id == project_id)
)).scalars().first()
if project and project.status == "paused":
project.status = "active"
await session.commit()
logger.info("Auto-reactivated paused project %d (%s)", project_id, project.title)
except Exception:
logger.debug("_maybe_reactivate_project failed for project %d", project_id, exc_info=True)
async def _maybe_trigger_project_summary(user_id: int, project_id: int) -> None: async def _maybe_trigger_project_summary(user_id: int, project_id: int) -> None:
"""Fire generate_project_summary() if the project summary is missing or >1h old.""" """Fire generate_project_summary() if the project summary is missing or >1h old."""
import asyncio import asyncio
@@ -92,6 +108,7 @@ async def create_note(
await session.refresh(note) await session.refresh(note)
if project_id is not None: if project_id is not None:
await _maybe_reactivate_project(project_id)
await _maybe_trigger_project_summary(user_id, project_id) await _maybe_trigger_project_summary(user_id, project_id)
return note return note
@@ -284,6 +301,7 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
await create_version(user_id, note_id, old_body, old_title, old_tags) await create_version(user_id, note_id, old_body, old_title, old_tags)
if note.project_id is not None: if note.project_id is not None:
await _maybe_reactivate_project(note.project_id)
await _maybe_trigger_project_summary(user_id, note.project_id) await _maybe_trigger_project_summary(user_id, note.project_id)
return note return note
@@ -103,7 +103,7 @@ async def send_password_reset_email(email: str, reset_url: str) -> None:
We received a request to reset your password. Click the button below to choose a new password. We received a request to reset your password. Click the button below to choose a new password.
</p> </p>
<div style="text-align:center;margin:24px 0;"> <div style="text-align:center;margin:24px 0;">
<a href="{reset_url}" style="display:inline-block;background:#6366f1;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;"> <a href="{reset_url}" style="display:inline-block;background:#7c3aed;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
Reset Password Reset Password
</a> </a>
</div> </div>
@@ -136,7 +136,7 @@ async def send_invitation_email(email: str, invite_url: str, invited_by_username
Click the button below to create your account. Click the button below to create your account.
</p> </p>
<div style="text-align:center;margin:24px 0;"> <div style="text-align:center;margin:24px 0;">
<a href="{invite_url}" style="display:inline-block;background:#6366f1;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;"> <a href="{invite_url}" style="display:inline-block;background:#7c3aed;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
Accept Invitation Accept Invitation
</a> </a>
</div> </div>