DRY refactoring pass: shared mixins, route helpers, composables, CSS

Backend:
- models/base.py: TimestampMixin + CreatedAtMixin; applied to all 10+ models
- routes/utils.py: not_found() + parse_iso_date() helpers; used across all route files
- routes/milestones.py: _milestone_dict() helper replaces 5 repeated to_dict + progress blocks

Frontend:
- 5 new composables: useAutoSave, useEditorGuards, useTagSuggestions,
  useFloatingAssist, useListKeyboardNavigation
- ConfirmDialog.vue: reusable confirm modal replacing inline <teleport> blocks
- editor-shared.css: sidebar CSS consolidated from both editor views
- viewer-shared.css: context-bar/breadcrumb CSS consolidated from both viewer views
- NoteEditorView + TaskEditorView: ~120 lines each replaced with composable calls;
  duplicate scoped sidebar CSS removed
- NotesListView + TasksListView: inline keyboard-nav replaced with composable
- NoteViewerView + TaskViewerView: duplicate context-bar CSS removed

No behaviour changes. Net: -634 lines, +237 lines across 31 files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 15:26:34 -05:00
parent 48f070f773
commit 16ecd6bbeb
32 changed files with 563 additions and 634 deletions
+64
View File
@@ -532,6 +532,70 @@
pointer-events: auto; pointer-events: auto;
} }
/* ── Sidebar shared styles ── */
.sidebar-toggle {
display: none;
width: 100%;
padding: 0.6rem 1rem;
background: var(--color-bg-secondary);
border: none;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
text-align: left;
font-family: inherit;
}
.sidebar-content {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.sb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sb-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sb-select,
.sb-input {
width: 100%;
padding: 0.35rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
}
.sb-select:focus,
.sb-input:focus {
outline: none;
border-color: var(--color-primary);
}
.sb-divider {
height: 1px;
background: var(--color-border);
margin: 0.15rem 0;
}
@media (max-width: 720px) {
.sidebar-toggle { display: block; }
.sidebar-content {
display: none;
padding: 0.75rem 1rem;
}
.sidebar-content.sidebar-open { display: flex; }
}
/* ── Mobile ── */ /* ── Mobile ── */
@media (max-width: 768px) { @media (max-width: 768px) {
.editor-body { .editor-body {
+41
View File
@@ -0,0 +1,41 @@
/* ── Context breadcrumb (shared by NoteViewerView and TaskViewerView) ── */
.context-bar {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 0.6rem;
}
.ctx-crumb {
display: inline-flex;
align-items: center;
font-size: 0.8rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
}
.ctx-crumb-parent {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
text-decoration: none;
}
.ctx-crumb-parent:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
.ctx-crumb-project {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
text-decoration: none;
font-weight: 500;
}
.ctx-crumb-project:hover {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
}
.ctx-crumb-milestone {
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
}
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
withDefaults(
defineProps<{
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
}>(),
{
confirmLabel: "Delete",
danger: true,
}
);
const emit = defineEmits<{
confirm: [];
cancel: [];
}>();
</script>
<template>
<teleport to="body">
<div class="modal-overlay" @click="emit('cancel')">
<div class="modal-card" @click.stop>
<h3 class="modal-title">{{ title }}</h3>
<p class="modal-message">{{ message }}</p>
<div class="modal-actions">
<button class="modal-btn" @click="emit('cancel')">Cancel</button>
<button
:class="['modal-btn', { 'modal-btn-danger': danger }]"
@click="emit('confirm')"
>{{ confirmLabel }}</button>
</div>
</div>
</div>
</teleport>
</template>
+22
View File
@@ -0,0 +1,22 @@
import { onMounted, onUnmounted } from "vue";
import type { Ref } from "vue";
export function useAutoSave(
dirty: Ref<boolean>,
saving: Ref<boolean>,
saveFn: () => Promise<void>,
intervalMs = 5 * 60 * 1000,
): void {
let timer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
timer = setInterval(async () => {
if (!dirty.value || saving.value) return;
await saveFn();
}, intervalMs);
});
onUnmounted(() => {
if (timer !== null) clearInterval(timer);
});
}
@@ -0,0 +1,33 @@
import { onMounted, onUnmounted } from "vue";
import { onBeforeRouteLeave } from "vue-router";
import type { Ref } from "vue";
export function useEditorGuards(
dirty: Ref<boolean>,
saveFn: () => void | Promise<void>,
): void {
function onKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
saveFn();
}
}
function onBeforeUnload(e: BeforeUnloadEvent) {
if (dirty.value) e.preventDefault();
}
onMounted(() => {
document.addEventListener("keydown", onKeydown);
window.addEventListener("beforeunload", onBeforeUnload);
});
onUnmounted(() => {
document.removeEventListener("keydown", onKeydown);
window.removeEventListener("beforeunload", onBeforeUnload);
});
onBeforeRouteLeave(() => {
if (dirty.value) return confirm("You have unsaved changes. Leave anyway?");
});
}
@@ -0,0 +1,34 @@
import { ref } from "vue";
type Selection = { text: string; start: number; end: number };
export function useFloatingAssist(onRequest: (sel: Selection) => void) {
const floatingAssist = ref({ show: false, top: 0, left: 0 });
const pendingSelection = ref<Selection | null>(null);
function onSelectionChange(payload: Selection) {
if (payload.text.trim().length > 0) {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const rect = sel.getRangeAt(0).getBoundingClientRect();
floatingAssist.value = {
show: true,
top: rect.top - 44,
left: rect.left + rect.width / 2,
};
pendingSelection.value = payload;
}
} else {
floatingAssist.value.show = false;
pendingSelection.value = null;
}
}
function handleInlineAssist() {
if (!pendingSelection.value) return;
floatingAssist.value.show = false;
onRequest(pendingSelection.value);
}
return { floatingAssist, onSelectionChange, handleInlineAssist };
}
@@ -0,0 +1,39 @@
import { ref, onMounted, onUnmounted } from "vue";
import type { Ref } from "vue";
export function useListKeyboardNavigation<T extends { id: number }>(
items: Ref<T[]>,
navigateTo: (item: T) => void,
rowSelector = ".kb-active-item",
enabled?: Ref<boolean>,
) {
const activeIndex = ref(-1);
function onKeydown(e: KeyboardEvent) {
if (enabled && !enabled.value) return;
const el = document.activeElement;
const tag = el ? (el as HTMLElement).tagName : "";
const isInput =
tag === "INPUT" || tag === "TEXTAREA" || (el as HTMLElement)?.isContentEditable;
if (isInput || e.ctrlKey || e.metaKey || e.altKey) return;
const count = items.value.length;
if (e.key === "j") {
e.preventDefault();
activeIndex.value = Math.min(activeIndex.value + 1, count - 1);
document.querySelector(rowSelector)?.scrollIntoView({ block: "nearest" });
} else if (e.key === "k") {
e.preventDefault();
activeIndex.value = Math.max(activeIndex.value - 1, 0);
document.querySelector(rowSelector)?.scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter" && activeIndex.value >= 0) {
const item = items.value[activeIndex.value];
if (item) navigateTo(item);
}
}
onMounted(() => document.addEventListener("keydown", onKeydown));
onUnmounted(() => document.removeEventListener("keydown", onKeydown));
return { activeIndex };
}
@@ -0,0 +1,58 @@
import { ref } from "vue";
import type { Ref } from "vue";
import { apiPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
export function useTagSuggestions(
title: Ref<string>,
body: Ref<string>,
tags: Ref<string[]>,
markDirty: () => void,
) {
const suggestedTags = ref<string[]>([]);
const appliedTags = ref<Set<string>>(new Set());
const suggestingTags = ref(false);
async function fetchTagSuggestions() {
if (suggestingTags.value) return;
suggestingTags.value = true;
suggestedTags.value = [];
appliedTags.value = new Set();
const toast = useToastStore();
try {
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
title: title.value,
body: body.value,
current_tags: tags.value,
});
suggestedTags.value = res.suggested_tags;
} catch {
toast.show("Failed to get tag suggestions", "error");
} finally {
suggestingTags.value = false;
}
}
function applyTagSuggestion(tag: string) {
if (appliedTags.value.has(tag)) return;
if (!tags.value.includes(tag)) {
tags.value = [...tags.value, tag];
}
appliedTags.value.add(tag);
markDirty();
}
function dismissTagSuggestions() {
suggestedTags.value = [];
appliedTags.value = new Set();
}
return {
suggestedTags,
appliedTags,
suggestingTags,
fetchTagSuggestions,
applyTagSuggestion,
dismissTagSuggestions,
};
}
+26 -161
View File
@@ -1,11 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue"; import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useNotesStore } from "@/stores/notes"; import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown"; import { renderMarkdown } from "@/utils/markdown";
import { useAssist, type NoteDraft } from "@/composables/useAssist"; import { useAssist, type NoteDraft } from "@/composables/useAssist";
import { apiPost, apiGet } from "@/api/client"; import { useAutoSave } from "@/composables/useAutoSave";
import { useEditorGuards } from "@/composables/useEditorGuards";
import { useTagSuggestions } from "@/composables/useTagSuggestions";
import { useFloatingAssist } from "@/composables/useFloatingAssist";
import { apiGet, apiPost } from "@/api/client";
import type { Editor } from "@tiptap/vue-3"; import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue"; import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue"; import TiptapEditor from "@/components/TiptapEditor.vue";
@@ -14,6 +18,7 @@ import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue"; import MilestoneSelector from "@/components/MilestoneSelector.vue";
import DiffView from "@/components/DiffView.vue"; import DiffView from "@/components/DiffView.vue";
import HistoryPanel from "@/components/HistoryPanel.vue"; import HistoryPanel from "@/components/HistoryPanel.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -84,31 +89,14 @@ const scopeSelectValue = computed({
}); });
// Floating inline assist button // Floating inline assist button
const floatingAssist = ref({ show: false, top: 0, left: 0 });
const pendingSelection = ref<{ start: number; end: number } | null>(null);
const instructionRef = ref<HTMLTextAreaElement | null>(null); const instructionRef = ref<HTMLTextAreaElement | null>(null);
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
function onSelectionChange(payload: { text: string; start: number; end: number }) { ({ start, end }) => {
if (payload.text.trim().length > 0) { assist.scopeMode.value = 'section';
const sel = window.getSelection(); assist.selectTextRange(start, end);
if (sel && sel.rangeCount > 0) { nextTick(() => instructionRef.value?.focus());
const rect = sel.getRangeAt(0).getBoundingClientRect();
floatingAssist.value = { show: true, top: rect.top - 44, left: rect.left + rect.width / 2 };
pendingSelection.value = { start: payload.start, end: payload.end };
}
} else {
floatingAssist.value.show = false;
pendingSelection.value = null;
} }
} );
function handleInlineAssist() {
if (!pendingSelection.value) return;
assist.scopeMode.value = 'section';
assist.selectTextRange(pendingSelection.value.start, pendingSelection.value.end);
floatingAssist.value.show = false;
nextTick(() => instructionRef.value?.focus());
}
function handleAssistAccept() { function handleAssistAccept() {
const newBody = assist.accept(); const newBody = assist.accept();
@@ -118,42 +106,8 @@ function handleAssistAccept() {
} }
// Tag suggestions // Tag suggestions
const suggestedTags = ref<string[]>([]); const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
const appliedTags = ref<Set<string>>(new Set()); useTagSuggestions(title, body, tags, markDirty);
const suggestingTags = ref(false);
async function fetchTagSuggestions() {
if (suggestingTags.value) return;
suggestingTags.value = true;
suggestedTags.value = [];
appliedTags.value = new Set();
try {
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
title: title.value,
body: body.value,
current_tags: tags.value,
});
suggestedTags.value = res.suggested_tags;
} catch {
toast.show("Failed to get tag suggestions", "error");
} finally {
suggestingTags.value = false;
}
}
function applyTagSuggestion(tag: string) {
if (appliedTags.value.has(tag)) return;
if (!tags.value.includes(tag)) {
tags.value = [...tags.value, tag];
}
appliedTags.value.add(tag);
markDirty();
}
function dismissTagSuggestions() {
suggestedTags.value = [];
appliedTags.value = new Set();
}
// ── Link Suggestions ──────────────────────────────────────────────────────── // ── Link Suggestions ────────────────────────────────────────────────────────
@@ -331,10 +285,8 @@ async function confirmDelete() {
} }
// Auto-save every 5 minutes // Auto-save every 5 minutes
let autoSaveTimer: ReturnType<typeof setInterval> | null = null; async function doAutoSave() {
if (!isEditing.value || saving.value) return;
async function autoSave() {
if (!isEditing.value || !dirty.value || saving.value) return;
saving.value = true; saving.value = true;
try { try {
await store.updateNote(noteId.value!, { await store.updateNote(noteId.value!, {
@@ -354,29 +306,8 @@ async function autoSave() {
saving.value = false; saving.value = false;
} }
} }
useAutoSave(dirty, saving, doAutoSave);
onMounted(() => { autoSaveTimer = setInterval(autoSave, 5 * 60 * 1000); }); useEditorGuards(dirty, save);
onUnmounted(() => { if (autoSaveTimer !== null) clearInterval(autoSaveTimer); });
function onKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
save();
}
}
onMounted(() => document.addEventListener("keydown", onKeydown));
onUnmounted(() => document.removeEventListener("keydown", onKeydown));
onBeforeRouteLeave(() => {
if (dirty.value) return confirm("You have unsaved changes. Leave anyway?");
});
function onBeforeUnload(e: BeforeUnloadEvent) {
if (dirty.value) e.preventDefault();
}
onMounted(() => window.addEventListener("beforeunload", onBeforeUnload));
onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
// Close any in-flight assist SSE stream when navigating away // Close any in-flight assist SSE stream when navigating away
onUnmounted(() => assist.clearSelection()); onUnmounted(() => assist.clearSelection());
@@ -595,18 +526,13 @@ onUnmounted(() => assist.clearSelection());
/> />
<!-- Delete confirmation --> <!-- Delete confirmation -->
<teleport to="body"> <ConfirmDialog
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false"> v-if="showDeleteConfirm"
<div class="modal-card" @click.stop> title="Delete Note"
<h3 class="modal-title">Delete Note</h3> message="Are you sure you want to delete this note? This cannot be undone."
<p class="modal-message">Are you sure you want to delete this note? This cannot be undone.</p> @confirm="confirmDelete"
<div class="modal-actions"> @cancel="showDeleteConfirm = false"
<button class="modal-btn" @click="showDeleteConfirm = false">Cancel</button> />
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
</div>
</div>
</div>
</teleport>
</main> </main>
</template> </template>
@@ -675,61 +601,6 @@ onUnmounted(() => assist.clearSelection());
flex-direction: column; flex-direction: column;
} }
/* Mobile accordion toggle (hidden on desktop) */
.sidebar-toggle {
display: none;
width: 100%;
padding: 0.6rem 1rem;
background: var(--color-bg-secondary);
border: none;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
text-align: left;
font-family: inherit;
}
.sidebar-content {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
/* Sidebar field layout */
.sb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sb-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sb-select {
width: 100%;
padding: 0.35rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
}
.sb-select:focus { outline: none; border-color: var(--color-primary); }
.sb-divider {
height: 1px;
background: var(--color-border);
margin: 0.15rem 0;
}
/* Tag suggest row inside sidebar */ /* Tag suggest row inside sidebar */
.tag-suggest-row { .tag-suggest-row {
display: flex; display: flex;
@@ -841,11 +712,5 @@ onUnmounted(() => assist.clearSelection());
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
overflow-y: visible; overflow-y: visible;
} }
.sidebar-toggle { display: block; }
.sidebar-content {
display: none;
padding: 0.75rem 1rem;
}
.sidebar-content.sidebar-open { display: flex; }
} }
</style> </style>
+1 -42
View File
@@ -229,6 +229,7 @@ async function convertToTask() {
</div> </div>
</template> </template>
<style src="@/assets/viewer-shared.css" />
<style scoped> <style scoped>
.viewer-layout { .viewer-layout {
display: flex; display: flex;
@@ -294,48 +295,6 @@ async function convertToTask() {
cursor: default; cursor: default;
} }
/* Context breadcrumb */
.context-bar {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 0.6rem;
}
.ctx-crumb {
display: inline-flex;
align-items: center;
font-size: 0.8rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
}
.ctx-crumb-parent {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
text-decoration: none;
}
.ctx-crumb-parent:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
.ctx-crumb-project {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
text-decoration: none;
font-weight: 500;
}
.ctx-crumb-project:hover {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
}
.ctx-crumb-milestone {
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
}
.meta { .meta {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text-muted); color: var(--color-text-muted);
+6 -28
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from "vue"; import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useNotesStore } from "@/stores/notes"; import { useNotesStore } from "@/stores/notes";
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
import SearchBar from "@/components/SearchBar.vue"; import SearchBar from "@/components/SearchBar.vue";
import NoteCard from "@/components/NoteCard.vue"; import NoteCard from "@/components/NoteCard.vue";
import TagPill from "@/components/TagPill.vue"; import TagPill from "@/components/TagPill.vue";
@@ -14,36 +15,15 @@ const router = useRouter();
const store = useNotesStore(); const store = useNotesStore();
const searchBarRef = ref<{ focus: () => void } | null>(null); const searchBarRef = ref<{ focus: () => void } | null>(null);
const activeIndex = ref(-1);
function onFocusSearch() { function onFocusSearch() {
searchBarRef.value?.focus(); searchBarRef.value?.focus();
} }
function onKeydown(e: KeyboardEvent) { const { activeIndex } = useListKeyboardNavigation(
const el = document.activeElement; computed(() => store.notes),
const tag = el ? (el as HTMLElement).tagName : ""; (note) => router.push(`/notes/${note.id}`),
const isInput = tag === "INPUT" || tag === "TEXTAREA" || (el as HTMLElement)?.isContentEditable; );
if (isInput || e.ctrlKey || e.metaKey || e.altKey) return;
const count = store.notes.length;
if (e.key === "j") {
e.preventDefault();
activeIndex.value = Math.min(activeIndex.value + 1, count - 1);
scrollActiveIntoView();
} else if (e.key === "k") {
e.preventDefault();
activeIndex.value = Math.max(activeIndex.value - 1, 0);
scrollActiveIntoView();
} else if (e.key === "Enter" && activeIndex.value >= 0) {
const note = store.notes[activeIndex.value];
if (note) router.push(`/notes/${note.id}`);
}
}
function scrollActiveIntoView() {
const el = document.querySelector(".kb-active-item");
if (el) el.scrollIntoView({ block: "nearest" });
}
const viewMode = ref<ViewMode>( const viewMode = ref<ViewMode>(
(localStorage.getItem("fabled-notes-view-mode") as ViewMode) ?? "grid" (localStorage.getItem("fabled-notes-view-mode") as ViewMode) ?? "grid"
@@ -63,12 +43,10 @@ onMounted(() => {
store.refresh(); store.refresh();
} }
document.addEventListener("shortcut:focus-search", onFocusSearch); document.addEventListener("shortcut:focus-search", onFocusSearch);
document.addEventListener("keydown", onKeydown);
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener("shortcut:focus-search", onFocusSearch); document.removeEventListener("shortcut:focus-search", onFocusSearch);
document.removeEventListener("keydown", onKeydown);
}); });
watch( watch(
+27 -171
View File
@@ -1,11 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue"; import { ref, onMounted, computed, nextTick } from "vue";
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks"; import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes"; import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown"; import { renderMarkdown } from "@/utils/markdown";
import { useAssist } from "@/composables/useAssist"; import { useAssist } from "@/composables/useAssist";
import { useAutoSave } from "@/composables/useAutoSave";
import { useEditorGuards } from "@/composables/useEditorGuards";
import { useTagSuggestions } from "@/composables/useTagSuggestions";
import { useFloatingAssist } from "@/composables/useFloatingAssist";
import { apiPost, apiGet, apiPatch } from "@/api/client"; import { apiPost, apiGet, apiPatch } from "@/api/client";
import type { TaskStatus, TaskPriority } from "@/types/task"; import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Editor } from "@tiptap/vue-3"; import type { Editor } from "@tiptap/vue-3";
@@ -16,6 +20,7 @@ import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue"; import MilestoneSelector from "@/components/MilestoneSelector.vue";
import TaskLogSection from "@/components/TaskLogSection.vue"; import TaskLogSection from "@/components/TaskLogSection.vue";
import InlineAssistPanel from "@/components/InlineAssistPanel.vue"; import InlineAssistPanel from "@/components/InlineAssistPanel.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -126,32 +131,15 @@ function toggleAssist() {
} }
// Floating inline assist button // Floating inline assist button
const floatingAssist = ref({ show: false, top: 0, left: 0 });
const pendingSelection = ref<{ start: number; end: number } | null>(null);
const instructionRef = ref<HTMLTextAreaElement | null>(null); const instructionRef = ref<HTMLTextAreaElement | null>(null);
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
function onSelectionChange(payload: { text: string; start: number; end: number }) { ({ start, end }) => {
if (payload.text.trim().length > 0) { assist.selectTextRange(start, end);
const sel = window.getSelection(); assistOpen.value = true;
if (sel && sel.rangeCount > 0) { localStorage.setItem(ASSIST_KEY, 'true');
const rect = sel.getRangeAt(0).getBoundingClientRect(); nextTick(() => instructionRef.value?.focus());
floatingAssist.value = { show: true, top: rect.top - 44, left: rect.left + rect.width / 2 };
pendingSelection.value = { start: payload.start, end: payload.end };
}
} else {
floatingAssist.value.show = false;
pendingSelection.value = null;
} }
} );
function handleInlineAssist() {
if (!pendingSelection.value) return;
assist.selectTextRange(pendingSelection.value.start, pendingSelection.value.end);
assistOpen.value = true;
localStorage.setItem(ASSIST_KEY, 'true');
floatingAssist.value.show = false;
nextTick(() => instructionRef.value?.focus());
}
function handleAssistAccept() { function handleAssistAccept() {
const newBody = assist.accept(); const newBody = assist.accept();
@@ -168,42 +156,8 @@ function truncateTarget(text: string, max = 60): string {
} }
// Tag suggestions // Tag suggestions
const suggestedTags = ref<string[]>([]); const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
const appliedTags = ref<Set<string>>(new Set()); useTagSuggestions(title, body, tags, markDirty);
const suggestingTags = ref(false);
async function fetchTagSuggestions() {
if (suggestingTags.value) return;
suggestingTags.value = true;
suggestedTags.value = [];
appliedTags.value = new Set();
try {
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
title: title.value,
body: body.value,
current_tags: tags.value,
});
suggestedTags.value = res.suggested_tags;
} catch {
toast.show("Failed to get tag suggestions", "error");
} finally {
suggestingTags.value = false;
}
}
function applyTagSuggestion(tag: string) {
if (appliedTags.value.has(tag)) return;
if (!tags.value.includes(tag)) {
tags.value = [...tags.value, tag];
}
appliedTags.value.add(tag);
markDirty();
}
function dismissTagSuggestions() {
suggestedTags.value = [];
appliedTags.value = new Set();
}
let savedTitle = ""; let savedTitle = "";
let savedBody = ""; let savedBody = "";
@@ -383,10 +337,8 @@ async function confirmDelete() {
} }
// Auto-save every 5 minutes when editing an existing task // Auto-save every 5 minutes when editing an existing task
let autoSaveTimer: ReturnType<typeof setInterval> | null = null; async function doAutoSave() {
if (!isEditing.value || saving.value) return;
async function autoSave() {
if (!isEditing.value || !dirty.value || saving.value) return;
saving.value = true; saving.value = true;
try { try {
await store.updateTask(taskId.value!, { await store.updateTask(taskId.value!, {
@@ -417,33 +369,8 @@ async function autoSave() {
saving.value = false; saving.value = false;
} }
} }
useAutoSave(dirty, saving, doAutoSave);
onMounted(() => { autoSaveTimer = setInterval(autoSave, 5 * 60 * 1000); }); useEditorGuards(dirty, save);
onUnmounted(() => { if (autoSaveTimer !== null) clearInterval(autoSaveTimer); });
function onKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
save();
}
}
onMounted(() => document.addEventListener("keydown", onKeydown));
onUnmounted(() => document.removeEventListener("keydown", onKeydown));
onBeforeRouteLeave(() => {
if (dirty.value) {
return confirm("You have unsaved changes. Leave anyway?");
}
});
function onBeforeUnload(e: BeforeUnloadEvent) {
if (dirty.value) {
e.preventDefault();
}
}
onMounted(() => window.addEventListener("beforeunload", onBeforeUnload));
onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</script> </script>
<template> <template>
@@ -710,18 +637,13 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</teleport> </teleport>
<!-- Delete confirmation --> <!-- Delete confirmation -->
<teleport to="body"> <ConfirmDialog
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false"> v-if="showDeleteConfirm"
<div class="modal-card" @click.stop> title="Delete Task"
<h3 class="modal-title">Delete Task</h3> message="Are you sure you want to delete this task? This cannot be undone."
<p class="modal-message">Are you sure you want to delete this task? This cannot be undone.</p> @confirm="confirmDelete"
<div class="modal-actions"> @cancel="showDeleteConfirm = false"
<button class="modal-btn" @click="showDeleteConfirm = false">Cancel</button> />
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
</div>
</div>
</div>
</teleport>
</main> </main>
</template> </template>
@@ -777,66 +699,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
flex-direction: column; flex-direction: column;
} }
/* Mobile accordion toggle (hidden on desktop) */
.sidebar-toggle {
display: none;
width: 100%;
padding: 0.6rem 1rem;
background: var(--color-bg-secondary);
border: none;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
text-align: left;
font-family: inherit;
}
.sidebar-content {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
/* Sidebar field layout */
.sb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sb-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sb-select,
.sb-input {
width: 100%;
padding: 0.35rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
}
.sb-select:focus,
.sb-input:focus {
outline: none;
border-color: var(--color-primary);
}
.sb-divider {
height: 1px;
background: var(--color-border);
margin: 0.15rem 0;
}
/* Parent task search */ /* Parent task search */
.parent-search-wrapper { position: relative; } .parent-search-wrapper { position: relative; }
.parent-input-row { display: flex; align-items: center; gap: 0.25rem; } .parent-input-row { display: flex; align-items: center; gap: 0.25rem; }
@@ -985,11 +847,5 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
overflow-y: visible; overflow-y: visible;
} }
.sidebar-toggle { display: block; }
.sidebar-content {
display: none;
padding: 0.75rem 1rem;
}
.sidebar-content.sidebar-open { display: flex; }
} }
</style> </style>
+1 -42
View File
@@ -315,6 +315,7 @@ const subTaskProgress = computed(() => {
</div> </div>
</template> </template>
<style src="@/assets/viewer-shared.css" />
<style scoped> <style scoped>
.viewer-layout { .viewer-layout {
display: flex; display: flex;
@@ -380,48 +381,6 @@ const subTaskProgress = computed(() => {
cursor: default; cursor: default;
} }
/* Context breadcrumb */
.context-bar {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 0.6rem;
}
.ctx-crumb {
display: inline-flex;
align-items: center;
font-size: 0.8rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
}
.ctx-crumb-parent {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
text-decoration: none;
}
.ctx-crumb-parent:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
.ctx-crumb-project {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
text-decoration: none;
font-weight: 500;
}
.ctx-crumb-project:hover {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
}
.ctx-crumb-milestone {
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
}
.meta { .meta {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text-muted); color: var(--color-text-muted);
+9 -24
View File
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks"; import { useTasksStore } from "@/stores/tasks";
import type { Task, TaskStatus } from "@/types/task"; import type { Task, TaskStatus } from "@/types/task";
import { apiGet } from "@/api/client"; import { apiGet } from "@/api/client";
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
import SearchBar from "@/components/SearchBar.vue"; import SearchBar from "@/components/SearchBar.vue";
import TaskCard from "@/components/TaskCard.vue"; import TaskCard from "@/components/TaskCard.vue";
import TagPill from "@/components/TagPill.vue"; import TagPill from "@/components/TagPill.vue";
@@ -16,33 +17,11 @@ const router = useRouter();
const store = useTasksStore(); const store = useTasksStore();
const searchBarRef = ref<{ focus: () => void } | null>(null); const searchBarRef = ref<{ focus: () => void } | null>(null);
const activeIndex = ref(-1);
function onFocusSearch() { function onFocusSearch() {
searchBarRef.value?.focus(); searchBarRef.value?.focus();
} }
function onKeydown(e: KeyboardEvent) {
if (viewMode.value !== "flat") return;
const el = document.activeElement;
const tag = el ? (el as HTMLElement).tagName : "";
const isInput = tag === "INPUT" || tag === "TEXTAREA" || (el as HTMLElement)?.isContentEditable;
if (isInput || e.ctrlKey || e.metaKey || e.altKey) return;
const count = store.tasks.length;
if (e.key === "j") {
e.preventDefault();
activeIndex.value = Math.min(activeIndex.value + 1, count - 1);
document.querySelector(".kb-active-item")?.scrollIntoView({ block: "nearest" });
} else if (e.key === "k") {
e.preventDefault();
activeIndex.value = Math.max(activeIndex.value - 1, 0);
document.querySelector(".kb-active-item")?.scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter" && activeIndex.value >= 0) {
const task = store.tasks[activeIndex.value];
if (task) router.push(`/tasks/${task.id}`);
}
}
const viewMode = ref<ViewMode>( const viewMode = ref<ViewMode>(
(localStorage.getItem("fabled-tasks-view-mode") as ViewMode) ?? "flat" (localStorage.getItem("fabled-tasks-view-mode") as ViewMode) ?? "flat"
); );
@@ -60,6 +39,14 @@ function setViewMode(mode: ViewMode) {
store.refresh(); store.refresh();
} }
const isFlat = computed(() => viewMode.value === "flat");
const { activeIndex } = useListKeyboardNavigation(
computed(() => store.tasks),
(task) => router.push(`/tasks/${task.id}`),
".kb-active-item",
isFlat,
);
// Project map for group labels and task breadcrumbs // Project map for group labels and task breadcrumbs
const projectMap = ref<Map<number, string>>(new Map()); const projectMap = ref<Map<number, string>>(new Map());
@@ -119,12 +106,10 @@ onMounted(async () => {
} }
await Promise.all([store.refresh(), loadProjects()]); await Promise.all([store.refresh(), loadProjects()]);
document.addEventListener("shortcut:focus-search", onFocusSearch); document.addEventListener("shortcut:focus-search", onFocusSearch);
document.addEventListener("keydown", onKeydown);
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener("shortcut:focus-search", onFocusSearch); document.removeEventListener("shortcut:focus-search", onFocusSearch);
document.removeEventListener("keydown", onKeydown);
}); });
watch( watch(
+3
View File
@@ -16,6 +16,9 @@ class Base(DeclarativeBase):
pass pass
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin # noqa: E402, F401
from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401 from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
from fabledassistant.models.conversation import Conversation, Message # noqa: E402, F401 from fabledassistant.models.conversation import Conversation, Message # noqa: E402, F401
from fabledassistant.models.setting import Setting # noqa: E402, F401 from fabledassistant.models.setting import Setting # noqa: E402, F401
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime
from sqlalchemy.orm import Mapped, mapped_column
class TimestampMixin:
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),
)
class CreatedAtMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+4 -16
View File
@@ -1,14 +1,13 @@
from datetime import datetime, timezone from sqlalchemy import ForeignKey, Index, Integer, Text
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy import inspect from sqlalchemy import inspect
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from fabledassistant.models import Base from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin
class Conversation(Base): class Conversation(Base, TimestampMixin):
__tablename__ = "conversations" __tablename__ = "conversations"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@@ -17,14 +16,6 @@ class Conversation(Base):
) )
title: Mapped[str] = mapped_column(Text, default="") title: Mapped[str] = mapped_column(Text, default="")
model: Mapped[str] = mapped_column(Text, default="") model: Mapped[str] = mapped_column(Text, default="")
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),
)
messages: Mapped[list["Message"]] = relationship( messages: Mapped[list["Message"]] = relationship(
back_populates="conversation", back_populates="conversation",
@@ -53,7 +44,7 @@ class Conversation(Base):
} }
class Message(Base): class Message(Base, CreatedAtMixin):
__tablename__ = "messages" __tablename__ = "messages"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@@ -67,9 +58,6 @@ class Message(Base):
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
) )
tool_calls: Mapped[list | None] = mapped_column(JSONB, nullable=True) tool_calls: Mapped[list | None] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
conversation: Mapped["Conversation"] = relationship(back_populates="messages") conversation: Mapped["Conversation"] = relationship(back_populates="messages")
+3 -12
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class Milestone(Base): class Milestone(Base, TimestampMixin):
__tablename__ = "milestones" __tablename__ = "milestones"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@@ -16,14 +15,6 @@ class Milestone(Base):
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(Text, default="active") status: Mapped[str] = mapped_column(Text, default="active")
order_index: Mapped[int] = mapped_column(Integer, default=0) order_index: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
+4 -11
View File
@@ -1,11 +1,12 @@
import enum import enum
from datetime import date, datetime, timezone from datetime import date
from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text from sqlalchemy import Date, ForeignKey, Index, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class TaskStatus(str, enum.Enum): class TaskStatus(str, enum.Enum):
@@ -21,7 +22,7 @@ class TaskPriority(str, enum.Enum):
high = "high" high = "high"
class Note(Base): class Note(Base, TimestampMixin):
__tablename__ = "notes" __tablename__ = "notes"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@@ -43,14 +44,6 @@ class Note(Base):
status: Mapped[str | None] = mapped_column(Text, nullable=True) status: Mapped[str | None] = mapped_column(Text, nullable=True)
priority: Mapped[str | None] = mapped_column(Text, nullable=True) priority: Mapped[str | None] = mapped_column(Text, nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True) due_date: Mapped[date | None] = mapped_column(Date, 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),
)
__table_args__ = ( __table_args__ = (
Index("ix_notes_tags", "tags", postgresql_using="gin"), Index("ix_notes_tags", "tags", postgresql_using="gin"),
+3 -12
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class NoteDraft(Base): class NoteDraft(Base, TimestampMixin):
__tablename__ = "note_drafts" __tablename__ = "note_drafts"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@@ -16,14 +15,6 @@ class NoteDraft(Base):
original_body: Mapped[str] = mapped_column(Text) original_body: Mapped[str] = mapped_column(Text)
instruction: Mapped[str] = mapped_column(Text, default="") instruction: Mapped[str] = mapped_column(Text, default="")
scope: Mapped[str] = mapped_column(Text, default="document") scope: Mapped[str] = mapped_column(Text, default="document")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
+3 -7
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone from sqlalchemy import ARRAY, ForeignKey, Integer, Text
from sqlalchemy import ARRAY, DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin
class NoteVersion(Base): class NoteVersion(Base, CreatedAtMixin):
__tablename__ = "note_versions" __tablename__ = "note_versions"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@@ -15,9 +14,6 @@ class NoteVersion(Base):
body: Mapped[str] = mapped_column(Text) body: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="") title: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list) tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
def to_dict(self, include_body: bool = True) -> dict: def to_dict(self, include_body: bool = True) -> dict:
d: dict = { d: dict = {
+3 -5
View File
@@ -1,8 +1,8 @@
import enum import enum
from datetime import datetime, timezone from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class ProjectStatus(str, enum.Enum): class ProjectStatus(str, enum.Enum):
@@ -11,7 +11,7 @@ class ProjectStatus(str, enum.Enum):
archived = "archived" archived = "archived"
class Project(Base): class Project(Base, TimestampMixin):
__tablename__ = "projects" __tablename__ = "projects"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True) user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
@@ -20,8 +20,6 @@ class Project(Base):
goal: Mapped[str] = mapped_column(Text, default="") goal: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(Text, default="active") status: Mapped[str] = mapped_column(Text, default="active")
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -1,10 +1,11 @@
from datetime import datetime, timezone from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text, UniqueConstraint from sqlalchemy import DateTime, ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin
class PushSubscription(Base): class PushSubscription(Base, CreatedAtMixin):
__tablename__ = "push_subscriptions" __tablename__ = "push_subscriptions"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE")) user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
@@ -12,7 +13,6 @@ class PushSubscription(Base):
p256dh: Mapped[str] = mapped_column(Text) p256dh: Mapped[str] = mapped_column(Text)
auth: Mapped[str] = mapped_column(Text) auth: Mapped[str] = mapped_column(Text)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
last_used: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_used: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = ( __table_args__ = (
+3 -12
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class TaskLog(Base): class TaskLog(Base, TimestampMixin):
__tablename__ = "task_logs" __tablename__ = "task_logs"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@@ -14,14 +13,6 @@ class TaskLog(Base):
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE")) user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
content: Mapped[str] = mapped_column(Text) content: Mapped[str] = mapped_column(Text)
duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
+3 -7
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone from sqlalchemy import Index, Text
from sqlalchemy import DateTime, Index, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin
class User(Base): class User(Base, CreatedAtMixin):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@@ -15,9 +14,6 @@ class User(Base):
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True) password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
oauth_sub: Mapped[str | None] = mapped_column(Text, unique=True, nullable=True) oauth_sub: Mapped[str | None] = mapped_column(Text, unique=True, nullable=True)
role: Mapped[str] = mapped_column(Text, nullable=False, default="user") role: Mapped[str] = mapped_column(Text, nullable=False, default="user")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = ( __table_args__ = (
Index("ix_users_username", "username"), Index("ix_users_username", "username"),
+9 -8
View File
@@ -6,6 +6,7 @@ import httpx
from quart import Blueprint, Response, jsonify, request from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.config import Config from fabledassistant.config import Config
from fabledassistant.services.chat import ( from fabledassistant.services.chat import (
add_message, add_message,
@@ -61,7 +62,7 @@ async def get_conversation_route(conv_id: int):
uid = get_current_user_id() uid = get_current_user_id()
conv = await get_conversation(uid, conv_id) conv = await get_conversation(uid, conv_id)
if conv is None: if conv is None:
return jsonify({"error": "Conversation not found"}), 404 return not_found("Conversation")
result = conv.to_dict() result = conv.to_dict()
note_ids = [m.context_note_id for m in conv.messages if m.context_note_id] note_ids = [m.context_note_id for m in conv.messages if m.context_note_id]
note_map = await get_notes_by_ids(uid, note_ids) if note_ids else {} note_map = await get_notes_by_ids(uid, note_ids) if note_ids else {}
@@ -80,7 +81,7 @@ async def delete_conversation_route(conv_id: int):
uid = get_current_user_id() uid = get_current_user_id()
deleted = await delete_conversation(uid, conv_id) deleted = await delete_conversation(uid, conv_id)
if not deleted: if not deleted:
return jsonify({"error": "Conversation not found"}), 404 return not_found("Conversation")
return "", 204 return "", 204
@@ -95,7 +96,7 @@ async def update_conversation_route(conv_id: int):
return jsonify({"error": "title or model is required"}), 400 return jsonify({"error": "title or model is required"}), 400
conv = await update_conversation(uid, conv_id, title=title, model=model) conv = await update_conversation(uid, conv_id, title=title, model=model)
if conv is None: if conv is None:
return jsonify({"error": "Conversation not found"}), 404 return not_found("Conversation")
return jsonify(conv.to_dict()) return jsonify(conv.to_dict())
@@ -106,7 +107,7 @@ async def send_message_route(conv_id: int):
uid = get_current_user_id() uid = get_current_user_id()
conv = await get_conversation(uid, conv_id) conv = await get_conversation(uid, conv_id)
if conv is None: if conv is None:
return jsonify({"error": "Conversation not found"}), 404 return not_found("Conversation")
data = await request.get_json() data = await request.get_json()
content = data.get("content", "").strip() content = data.get("content", "").strip()
@@ -166,7 +167,7 @@ async def generation_stream_route(conv_id: int):
uid = get_current_user_id() uid = get_current_user_id()
conv = await get_conversation(uid, conv_id) conv = await get_conversation(uid, conv_id)
if conv is None: if conv is None:
return jsonify({"error": "Conversation not found"}), 404 return not_found("Conversation")
buf = get_buffer(conv_id) buf = get_buffer(conv_id)
if buf is None: if buf is None:
@@ -219,7 +220,7 @@ async def confirm_generation_route(conv_id: int):
uid = get_current_user_id() uid = get_current_user_id()
conv = await get_conversation(uid, conv_id) conv = await get_conversation(uid, conv_id)
if conv is None: if conv is None:
return jsonify({"error": "Conversation not found"}), 404 return not_found("Conversation")
buf = get_buffer(conv_id) buf = get_buffer(conv_id)
if buf is None or buf.state != GenerationState.RUNNING: if buf is None or buf.state != GenerationState.RUNNING:
@@ -241,7 +242,7 @@ async def cancel_generation_route(conv_id: int):
uid = get_current_user_id() uid = get_current_user_id()
conv = await get_conversation(uid, conv_id) conv = await get_conversation(uid, conv_id)
if conv is None: if conv is None:
return jsonify({"error": "Conversation not found"}), 404 return not_found("Conversation")
buf = get_buffer(conv_id) buf = get_buffer(conv_id)
if buf is None or buf.state != GenerationState.RUNNING: if buf is None or buf.state != GenerationState.RUNNING:
@@ -268,7 +269,7 @@ async def summarize_conversation_route(conv_id: int):
uid = get_current_user_id() uid = get_current_user_id()
conv = await get_conversation(uid, conv_id) conv = await get_conversation(uid, conv_id)
if conv is None: if conv is None:
return jsonify({"error": "Conversation not found"}), 404 return not_found("Conversation")
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
try: try:
note = await summarize_conversation_as_note(uid, conv_id, model) note = await summarize_conversation_as_note(uid, conv_id, model)
+19 -23
View File
@@ -4,6 +4,7 @@ import logging
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.services.milestones import ( from fabledassistant.services.milestones import (
create_milestone, create_milestone,
delete_milestone, delete_milestone,
@@ -20,21 +21,22 @@ logger = logging.getLogger(__name__)
milestones_bp = Blueprint("milestones", __name__, url_prefix="/api/projects") milestones_bp = Blueprint("milestones", __name__, url_prefix="/api/projects")
async def _milestone_dict(m) -> dict:
d = m.to_dict()
d.update(await get_milestone_progress(m.id))
return d
@milestones_bp.route("/<int:project_id>/milestones", methods=["GET"]) @milestones_bp.route("/<int:project_id>/milestones", methods=["GET"])
@login_required @login_required
async def list_milestones_route(project_id: int): async def list_milestones_route(project_id: int):
uid = get_current_user_id() uid = get_current_user_id()
project = await get_project(uid, project_id) project = await get_project(uid, project_id)
if project is None: if project is None:
return jsonify({"error": "Project not found"}), 404 return not_found("Project")
status = request.args.get("status") status = request.args.get("status")
milestones = await list_milestones(uid, project_id, status=status) milestones = await list_milestones(uid, project_id, status=status)
result = [] result = [await _milestone_dict(m) for m in milestones]
for m in milestones:
entry = m.to_dict()
progress = await get_milestone_progress(m.id)
entry.update(progress)
result.append(entry)
return jsonify({"milestones": result}) return jsonify({"milestones": result})
@@ -44,7 +46,7 @@ async def create_milestone_route(project_id: int):
uid = get_current_user_id() uid = get_current_user_id()
project = await get_project(uid, project_id) project = await get_project(uid, project_id)
if project is None: if project is None:
return jsonify({"error": "Project not found"}), 404 return not_found("Project")
data = await request.get_json() data = await request.get_json()
if not data.get("title"): if not data.get("title"):
return jsonify({"error": "title is required"}), 400 return jsonify({"error": "title is required"}), 400
@@ -55,9 +57,7 @@ async def create_milestone_route(project_id: int):
description=data.get("description"), description=data.get("description"),
order_index=data.get("order_index", 0), order_index=data.get("order_index", 0),
) )
entry = milestone.to_dict() return jsonify(await _milestone_dict(milestone)), 201
entry.update(await get_milestone_progress(milestone.id))
return jsonify(entry), 201
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["GET"]) @milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["GET"])
@@ -66,10 +66,8 @@ async def get_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id() uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id) milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id: if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404 return not_found("Milestone")
entry = milestone.to_dict() return jsonify(await _milestone_dict(milestone))
entry.update(await get_milestone_progress(milestone.id))
return jsonify(entry)
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["PATCH"]) @milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["PATCH"])
@@ -78,16 +76,14 @@ async def update_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id() uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id) milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id: if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404 return not_found("Milestone")
data = await request.get_json() data = await request.get_json()
allowed = {"title", "description", "status", "order_index"} allowed = {"title", "description", "status", "order_index"}
fields = {k: v for k, v in data.items() if k in allowed} fields = {k: v for k, v in data.items() if k in allowed}
updated = await update_milestone(uid, milestone_id, **fields) updated = await update_milestone(uid, milestone_id, **fields)
if updated is None: if updated is None:
return jsonify({"error": "Milestone not found"}), 404 return not_found("Milestone")
entry = updated.to_dict() return jsonify(await _milestone_dict(updated))
entry.update(await get_milestone_progress(updated.id))
return jsonify(entry)
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["DELETE"]) @milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["DELETE"])
@@ -96,10 +92,10 @@ async def delete_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id() uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id) milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id: if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404 return not_found("Milestone")
deleted = await delete_milestone(uid, milestone_id) deleted = await delete_milestone(uid, milestone_id)
if not deleted: if not deleted:
return jsonify({"error": "Milestone not found"}), 404 return not_found("Milestone")
return "", 204 return "", 204
@@ -109,7 +105,7 @@ async def get_milestone_tasks_route(project_id: int, milestone_id: int):
uid = get_current_user_id() uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id) milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id: if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404 return not_found("Milestone")
status_filter = request.args.get("status") status_filter = request.args.get("status")
limit = min(request.args.get("limit", 100, type=int), 500) limit = min(request.args.get("limit", 100, type=int), 500)
offset = request.args.get("offset", 0, type=int) offset = request.args.get("offset", 0, type=int)
+20 -23
View File
@@ -1,13 +1,13 @@
import asyncio import asyncio
import logging import logging
import re import re
from datetime import date
from fabledassistant.services.embeddings import upsert_note_embedding from fabledassistant.services.embeddings import upsert_note_embedding
from quart import Blueprint, Response, jsonify, request from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_iso_date
from fabledassistant.config import Config from fabledassistant.config import Config
from fabledassistant.services.assist import build_assist_messages from fabledassistant.services.assist import build_assist_messages
from fabledassistant.services.generation_buffer import ( from fabledassistant.services.generation_buffer import (
@@ -87,12 +87,9 @@ async def create_note_route():
# Optional task fields # Optional task fields
status = data.get("status") status = data.get("status")
priority = data.get("priority") priority = data.get("priority")
due_date = None due_date = parse_iso_date(data.get("due_date"), "due_date")
if data.get("due_date"): if isinstance(due_date, tuple):
try: return due_date
due_date = date.fromisoformat(data["due_date"])
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
note = await create_note( note = await create_note(
uid, uid,
@@ -144,7 +141,7 @@ async def append_tag_route(note_id: int):
note = await get_note(uid, note_id) note = await get_note(uid, note_id)
if note is None: if note is None:
return jsonify({"error": "Note not found"}), 404 return not_found("Note")
existing = list(note.tags or []) existing = list(note.tags or [])
if tag not in existing: if tag not in existing:
@@ -162,7 +159,7 @@ async def get_note_by_title_route():
return jsonify({"error": "title parameter is required"}), 400 return jsonify({"error": "title parameter is required"}), 400
note = await get_note_by_title(uid, title) note = await get_note_by_title(uid, title)
if note is None: if note is None:
return jsonify({"error": "Note not found"}), 404 return not_found("Note")
return jsonify(note.to_dict()) return jsonify(note.to_dict())
@@ -184,7 +181,7 @@ async def get_note_route(note_id: int):
uid = get_current_user_id() uid = get_current_user_id()
note = await get_note(uid, note_id) note = await get_note(uid, note_id)
if note is None: if note is None:
return jsonify({"error": "Note not found"}), 404 return not_found("Note")
return jsonify(note.to_dict()) return jsonify(note.to_dict())
@@ -200,10 +197,10 @@ async def update_note_route(note_id: int):
if "due_date" in data: if "due_date" in data:
if data["due_date"]: if data["due_date"]:
try: result = parse_iso_date(data["due_date"], "due_date")
fields["due_date"] = date.fromisoformat(data["due_date"]) if isinstance(result, tuple):
except ValueError: return result
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400 fields["due_date"] = result
else: else:
fields["due_date"] = None fields["due_date"] = None
@@ -211,7 +208,7 @@ async def update_note_route(note_id: int):
fields["tags"] = data["tags"] fields["tags"] = data["tags"]
note = await update_note(uid, note_id, **fields) note = await update_note(uid, note_id, **fields)
if note is None: if note is None:
return jsonify({"error": "Note not found"}), 404 return not_found("Note")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "") text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text: if text:
asyncio.create_task(upsert_note_embedding(note.id, uid, text)) asyncio.create_task(upsert_note_embedding(note.id, uid, text))
@@ -228,15 +225,15 @@ async def patch_note_route(note_id: int):
if key in data: if key in data:
fields[key] = data[key] fields[key] = data[key]
if "due_date" in data: if "due_date" in data:
try: result = parse_iso_date(data.get("due_date"), "due_date")
fields["due_date"] = date.fromisoformat(data["due_date"]) if data["due_date"] else None if isinstance(result, tuple):
except (ValueError, TypeError): return result
return jsonify({"error": "Invalid due_date format, expected YYYY-MM-DD"}), 400 fields["due_date"] = result
if "tags" in data: if "tags" in data:
fields["tags"] = data["tags"] fields["tags"] = data["tags"]
note = await update_note(uid, note_id, **fields) note = await update_note(uid, note_id, **fields)
if note is None: if note is None:
return jsonify({"error": "Note not found"}), 404 return not_found("Note")
return jsonify(note.to_dict()) return jsonify(note.to_dict())
@@ -246,7 +243,7 @@ async def delete_note_route(note_id: int):
uid = get_current_user_id() uid = get_current_user_id()
deleted = await delete_note(uid, note_id) deleted = await delete_note(uid, note_id)
if not deleted: if not deleted:
return jsonify({"error": "Note not found"}), 404 return not_found("Note")
return "", 204 return "", 204
@@ -474,7 +471,7 @@ async def upsert_draft_route(note_id: int):
# Verify note ownership # Verify note ownership
note = await get_note(uid, note_id) note = await get_note(uid, note_id)
if note is None: if note is None:
return jsonify({"error": "Note not found"}), 404 return not_found("Note")
data = await request.get_json() data = await request.get_json()
draft = await upsert_draft( draft = await upsert_draft(
user_id=uid, user_id=uid,
@@ -511,5 +508,5 @@ async def get_version_route(note_id: int, version_id: int):
uid = get_current_user_id() uid = get_current_user_id()
version = await get_version(uid, note_id, version_id) version = await get_version(uid, note_id, version_id)
if version is None: if version is None:
return jsonify({"error": "Version not found"}), 404 return not_found("Version")
return jsonify(version.to_dict(include_body=True)) return jsonify(version.to_dict(include_body=True))
+5 -4
View File
@@ -4,6 +4,7 @@ import logging
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.services.milestones import list_milestones from fabledassistant.services.milestones import list_milestones
from fabledassistant.services.notes import list_notes from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import ( from fabledassistant.services.projects import (
@@ -52,7 +53,7 @@ async def get_project_route(project_id: int):
uid = get_current_user_id() uid = get_current_user_id()
project = await get_project(uid, project_id) project = await get_project(uid, project_id)
if project is None: if project is None:
return jsonify({"error": "Project not found"}), 404 return not_found("Project")
summary = await get_project_summary(uid, project_id) summary = await get_project_summary(uid, project_id)
data = project.to_dict() data = project.to_dict()
data["summary"] = summary data["summary"] = summary
@@ -68,7 +69,7 @@ async def update_project_route(project_id: int):
fields = {k: v for k, v in data.items() if k in allowed} fields = {k: v for k, v in data.items() if k in allowed}
project = await update_project(uid, project_id, **fields) project = await update_project(uid, project_id, **fields)
if project is None: if project is None:
return jsonify({"error": "Project not found"}), 404 return not_found("Project")
return jsonify(project.to_dict()) return jsonify(project.to_dict())
@@ -78,7 +79,7 @@ async def delete_project_route(project_id: int):
uid = get_current_user_id() uid = get_current_user_id()
deleted = await delete_project(uid, project_id) deleted = await delete_project(uid, project_id)
if not deleted: if not deleted:
return jsonify({"error": "Project not found"}), 404 return not_found("Project")
return "", 204 return "", 204
@@ -88,7 +89,7 @@ async def get_project_notes_route(project_id: int):
uid = get_current_user_id() uid = get_current_user_id()
project = await get_project(uid, project_id) project = await get_project(uid, project_id)
if project is None: if project is None:
return jsonify({"error": "Project not found"}), 404 return not_found("Project")
# type filter: "note", "task", or None (both) # type filter: "note", "task", or None (both)
type_filter = request.args.get("type") type_filter = request.args.get("type")
+18 -23
View File
@@ -1,9 +1,8 @@
from datetime import date
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.models.note import TaskPriority, TaskStatus from fabledassistant.models.note import TaskPriority, TaskStatus
from fabledassistant.routes.utils import not_found, parse_iso_date
from fabledassistant.services.notes import ( from fabledassistant.services.notes import (
create_note, create_note,
delete_note, delete_note,
@@ -28,13 +27,12 @@ async def list_tasks_route():
limit = min(request.args.get("limit", 50, type=int), 500) limit = min(request.args.get("limit", 50, type=int), 500)
offset = request.args.get("offset", 0, type=int) offset = request.args.get("offset", 0, type=int)
due_before_str = request.args.get("due_before") due_before = parse_iso_date(request.args.get("due_before"), "due_before")
due_after_str = request.args.get("due_after") if isinstance(due_before, tuple):
try: return due_before
due_before = date.fromisoformat(due_before_str) if due_before_str else None due_after = parse_iso_date(request.args.get("due_after"), "due_after")
due_after = date.fromisoformat(due_after_str) if due_after_str else None if isinstance(due_after, tuple):
except ValueError: return due_after
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
tasks, total = await list_notes( tasks, total = await list_notes(
uid, uid,
@@ -61,12 +59,9 @@ async def create_task_route():
body = data.get("body", "") or data.get("description", "") body = data.get("body", "") or data.get("description", "")
tags = data.get("tags", []) tags = data.get("tags", [])
due_date = None due_date = parse_iso_date(data.get("due_date"), "due_date")
if data.get("due_date"): if isinstance(due_date, tuple):
try: return due_date
due_date = date.fromisoformat(data["due_date"])
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
priority = ( priority = (
@@ -94,7 +89,7 @@ async def get_task_route(task_id: int):
uid = get_current_user_id() uid = get_current_user_id()
task = await get_note(uid, task_id) task = await get_note(uid, task_id)
if task is None: if task is None:
return jsonify({"error": "Task not found"}), 404 return not_found("Task")
data = task.to_dict() data = task.to_dict()
if task.parent_id: if task.parent_id:
parent = await get_note(uid, task.parent_id) parent = await get_note(uid, task.parent_id)
@@ -120,10 +115,10 @@ async def update_task_route(task_id: int):
if "due_date" in data: if "due_date" in data:
if data["due_date"]: if data["due_date"]:
try: result = parse_iso_date(data["due_date"], "due_date")
fields["due_date"] = date.fromisoformat(data["due_date"]) if isinstance(result, tuple):
except ValueError: return result
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400 fields["due_date"] = result
else: else:
fields["due_date"] = None fields["due_date"] = None
@@ -136,7 +131,7 @@ async def update_task_route(task_id: int):
task = await update_note(uid, task_id, **fields) task = await update_note(uid, task_id, **fields)
if task is None: if task is None:
return jsonify({"error": "Task not found"}), 404 return not_found("Task")
return jsonify(task.to_dict()) return jsonify(task.to_dict())
@@ -156,7 +151,7 @@ async def patch_task_status(task_id: int):
task = await update_note(uid, task_id, status=status_val) task = await update_note(uid, task_id, status=status_val)
if task is None: if task is None:
return jsonify({"error": "Task not found"}), 404 return not_found("Task")
return jsonify(task.to_dict()) return jsonify(task.to_dict())
@@ -166,5 +161,5 @@ async def delete_task_route(task_id: int):
uid = get_current_user_id() uid = get_current_user_id()
deleted = await delete_note(uid, task_id) deleted = await delete_note(uid, task_id)
if not deleted: if not deleted:
return jsonify({"error": "Task not found"}), 404 return not_found("Task")
return "", 204 return "", 204
+17
View File
@@ -0,0 +1,17 @@
from datetime import date
from quart import jsonify
def not_found(resource: str = "Item"):
return jsonify({"error": f"{resource} not found"}), 404
def parse_iso_date(value: str | None, field: str = "date"):
"""Parse an ISO date string. Returns a date, None, or a (response, 400) tuple."""
if not value:
return None
try:
return date.fromisoformat(value)
except ValueError:
return jsonify({"error": f"Invalid {field} format. Use YYYY-MM-DD."}), 400
+24
View File
@@ -12,6 +12,30 @@
> Include file-level details in the commit body when the change is non-trivial. > Include file-level details in the commit body when the change is non-trivial.
## Last Updated ## Last Updated
2026-03-06 — DRY refactoring pass: TimestampMixin, route helpers, shared composables, ConfirmDialog, shared CSS.
**DRY refactoring pass (backend):**
- `src/fabledassistant/models/base.py` (new): `TimestampMixin` (`created_at` + `updated_at`) and `CreatedAtMixin` (`created_at` only) — SQLAlchemy 2.0 `Mapped[]` mixins. Applied to all 10+ models (`Note`, `Project`, `Milestone`, `Conversation`, `Message`, `User`, `PushSubscription`, `TaskLog`, `NoteDraft`, `NoteVersion`), removing ~60 lines of duplicated column definitions.
- `src/fabledassistant/routes/utils.py` (new): `not_found(resource)``(Response, 404)` and `parse_iso_date(value, field)``date | None | (Response, 400)`. Used across `notes.py`, `tasks.py`, `projects.py`, `milestones.py`, `chat.py`, replacing ~18 inline 404 blocks and ~4 duplicate date-parsing try/except blocks.
- `routes/milestones.py`: `_milestone_dict(m)` local async helper replaces 5 repetitions of `entry = m.to_dict(); entry.update(await get_milestone_progress(m.id))`.
**DRY refactoring pass (frontend):**
- `frontend/src/composables/useAutoSave.ts` (new): `useAutoSave(dirty, saving, saveFn, intervalMs?)` — interval-based autosave with `onMounted`/`onUnmounted` lifecycle, guards on dirty+saving state.
- `frontend/src/composables/useEditorGuards.ts` (new): `useEditorGuards(dirty, saveFn)` — registers Ctrl+S handler, `beforeunload` warning, and `onBeforeRouteLeave` confirm guard.
- `frontend/src/composables/useTagSuggestions.ts` (new): `useTagSuggestions(title, body, tags, markDirty)` — encapsulates `/api/notes/suggest-tags` call and suggestion state.
- `frontend/src/composables/useFloatingAssist.ts` (new): `useFloatingAssist(onRequest)` — floating selection-based assist button positioning.
- `frontend/src/composables/useListKeyboardNavigation.ts` (new): `useListKeyboardNavigation<T>(items, navigateTo, rowSelector?, enabled?)` — j/k/Enter navigation with focus-in-input guard and optional `enabled` ref.
- `frontend/src/components/ConfirmDialog.vue` (new): reusable confirm modal. Props: `title`, `message`, `confirmLabel` (default "Delete"), `danger` (default true). Emits `confirm`/`cancel`. Uses `<teleport to="body">` internally.
- `frontend/src/assets/editor-shared.css`: added shared sidebar CSS (`.sidebar-toggle`, `.sidebar-content`, `.sb-field`, `.sb-label`, `.sb-select`, `.sb-input`, `.sb-divider` + mobile collapse rules) used by both editor views.
- `frontend/src/assets/viewer-shared.css` (new): shared context-breadcrumb CSS (`.context-bar`, `.ctx-crumb*`) imported by `NoteViewerView` and `TaskViewerView`.
- `NoteEditorView.vue`, `TaskEditorView.vue`: replaced ~120 lines each of duplicated floating-assist, tag-suggestion, autosave, and route-guard logic with composable calls; replaced inline `<teleport>` delete modal with `<ConfirmDialog>`; removed ~80 lines of duplicated scoped sidebar CSS.
- `NotesListView.vue`, `TasksListView.vue`: replaced inline `activeIndex`/`onKeydown`/`scrollActiveIntoView` keyboard-nav (~25 lines each) with `useListKeyboardNavigation`. TasksListView passes `isFlat` computed ref as `enabled` guard.
- `NoteViewerView.vue`, `TaskViewerView.vue`: removed ~40 lines of duplicate `.context-bar`/`.ctx-crumb*` scoped CSS; added `<style src="@/assets/viewer-shared.css" />`.
- No behaviour changes. Net: 634 lines, +237 lines across 31 files.
---
## Previous Session
2026-03-06 — Project-aware writing assist, link suggestions, project-scoped RAG, semantic note search tool, SSE done-event race fix, RAG snippet increase, sidebar include full body, Enter-to-submit assist. 2026-03-06 — Project-aware writing assist, link suggestions, project-scoped RAG, semantic note search tool, SSE done-event race fix, RAG snippet increase, sidebar include full body, Enter-to-submit assist.
**SSE done-event race fix:** `routes/notes.py` and `routes/chat.py` — The SSE stream generator had a race condition where `yield buf.format_sse(event)` hands control back to the event loop, allowing the generation task to set `buf.state = COMPLETED` and append the `done` event between the last yield and the state check. The loop would then break without delivering the `done` event (containing `full_text`). Fixed by adding a final drain loop after the `while` exits in both stream generators. Added INFO-level logging to `run_assist_generation` showing `input_chars`, `output_chars`, event count, and done-event index. **SSE done-event race fix:** `routes/notes.py` and `routes/chat.py` — The SSE stream generator had a race condition where `yield buf.format_sse(event)` hands control back to the event loop, allowing the generation task to set `buf.state = COMPLETED` and append the `done` event between the last yield and the state check. The loop would then break without delivering the `done` event (containing `full_text`). Fixed by adding a final drain loop after the `while` exits in both stream generators. Added INFO-level logging to `run_assist_generation` showing `input_chars`, `output_chars`, event count, and done-event index.