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:
@@ -532,6 +532,70 @@
|
||||
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 ── */
|
||||
@media (max-width: 768px) {
|
||||
.editor-body {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
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 { useToastStore } from "@/stores/toast";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
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 MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||
@@ -14,6 +18,7 @@ import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import HistoryPanel from "@/components/HistoryPanel.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -84,31 +89,14 @@ const scopeSelectValue = computed({
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
function onSelectionChange(payload: { text: string; start: number; end: number }) {
|
||||
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 = { start: payload.start, end: payload.end };
|
||||
}
|
||||
} else {
|
||||
floatingAssist.value.show = false;
|
||||
pendingSelection.value = null;
|
||||
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
|
||||
({ start, end }) => {
|
||||
assist.scopeMode.value = 'section';
|
||||
assist.selectTextRange(start, end);
|
||||
nextTick(() => instructionRef.value?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
const newBody = assist.accept();
|
||||
@@ -118,42 +106,8 @@ function handleAssistAccept() {
|
||||
}
|
||||
|
||||
// Tag suggestions
|
||||
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();
|
||||
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();
|
||||
}
|
||||
const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
|
||||
useTagSuggestions(title, body, tags, markDirty);
|
||||
|
||||
// ── Link Suggestions ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -331,10 +285,8 @@ async function confirmDelete() {
|
||||
}
|
||||
|
||||
// Auto-save every 5 minutes
|
||||
let autoSaveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function autoSave() {
|
||||
if (!isEditing.value || !dirty.value || saving.value) return;
|
||||
async function doAutoSave() {
|
||||
if (!isEditing.value || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await store.updateNote(noteId.value!, {
|
||||
@@ -354,29 +306,8 @@ async function autoSave() {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { autoSaveTimer = setInterval(autoSave, 5 * 60 * 1000); });
|
||||
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));
|
||||
useAutoSave(dirty, saving, doAutoSave);
|
||||
useEditorGuards(dirty, save);
|
||||
|
||||
// Close any in-flight assist SSE stream when navigating away
|
||||
onUnmounted(() => assist.clearSelection());
|
||||
@@ -595,18 +526,13 @@ onUnmounted(() => assist.clearSelection());
|
||||
/>
|
||||
|
||||
<!-- Delete confirmation -->
|
||||
<teleport to="body">
|
||||
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
|
||||
<div class="modal-card" @click.stop>
|
||||
<h3 class="modal-title">Delete Note</h3>
|
||||
<p class="modal-message">Are you sure you want to delete this note? This cannot be undone.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-btn" @click="showDeleteConfirm = false">Cancel</button>
|
||||
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
<ConfirmDialog
|
||||
v-if="showDeleteConfirm"
|
||||
title="Delete Note"
|
||||
message="Are you sure you want to delete this note? This cannot be undone."
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -675,61 +601,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -841,11 +712,5 @@ onUnmounted(() => assist.clearSelection());
|
||||
border-top: 1px solid var(--color-border);
|
||||
overflow-y: visible;
|
||||
}
|
||||
.sidebar-toggle { display: block; }
|
||||
.sidebar-content {
|
||||
display: none;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.sidebar-content.sidebar-open { display: flex; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -229,6 +229,7 @@ async function convertToTask() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/viewer-shared.css" />
|
||||
<style scoped>
|
||||
.viewer-layout {
|
||||
display: flex;
|
||||
@@ -294,48 +295,6 @@ async function convertToTask() {
|
||||
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 {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<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 { useNotesStore } from "@/stores/notes";
|
||||
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
|
||||
import SearchBar from "@/components/SearchBar.vue";
|
||||
import NoteCard from "@/components/NoteCard.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
@@ -14,36 +15,15 @@ const router = useRouter();
|
||||
const store = useNotesStore();
|
||||
|
||||
const searchBarRef = ref<{ focus: () => void } | null>(null);
|
||||
const activeIndex = ref(-1);
|
||||
|
||||
function onFocusSearch() {
|
||||
searchBarRef.value?.focus();
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
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.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 { activeIndex } = useListKeyboardNavigation(
|
||||
computed(() => store.notes),
|
||||
(note) => router.push(`/notes/${note.id}`),
|
||||
);
|
||||
|
||||
const viewMode = ref<ViewMode>(
|
||||
(localStorage.getItem("fabled-notes-view-mode") as ViewMode) ?? "grid"
|
||||
@@ -63,12 +43,10 @@ onMounted(() => {
|
||||
store.refresh();
|
||||
}
|
||||
document.addEventListener("shortcut:focus-search", onFocusSearch);
|
||||
document.addEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("shortcut:focus-search", onFocusSearch);
|
||||
document.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
||||
watch(
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
|
||||
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
|
||||
import { ref, onMounted, computed, nextTick } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import { useNotesStore } from "@/stores/notes";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
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 type { TaskStatus, TaskPriority } from "@/types/task";
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
@@ -16,6 +20,7 @@ import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||
import InlineAssistPanel from "@/components/InlineAssistPanel.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -126,32 +131,15 @@ function toggleAssist() {
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
function onSelectionChange(payload: { text: string; start: number; end: number }) {
|
||||
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 = { start: payload.start, end: payload.end };
|
||||
}
|
||||
} else {
|
||||
floatingAssist.value.show = false;
|
||||
pendingSelection.value = null;
|
||||
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
|
||||
({ start, end }) => {
|
||||
assist.selectTextRange(start, end);
|
||||
assistOpen.value = true;
|
||||
localStorage.setItem(ASSIST_KEY, 'true');
|
||||
nextTick(() => instructionRef.value?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
const newBody = assist.accept();
|
||||
@@ -168,42 +156,8 @@ function truncateTarget(text: string, max = 60): string {
|
||||
}
|
||||
|
||||
// Tag suggestions
|
||||
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();
|
||||
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();
|
||||
}
|
||||
const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
|
||||
useTagSuggestions(title, body, tags, markDirty);
|
||||
|
||||
let savedTitle = "";
|
||||
let savedBody = "";
|
||||
@@ -383,10 +337,8 @@ async function confirmDelete() {
|
||||
}
|
||||
|
||||
// Auto-save every 5 minutes when editing an existing task
|
||||
let autoSaveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function autoSave() {
|
||||
if (!isEditing.value || !dirty.value || saving.value) return;
|
||||
async function doAutoSave() {
|
||||
if (!isEditing.value || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await store.updateTask(taskId.value!, {
|
||||
@@ -417,33 +369,8 @@ async function autoSave() {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { autoSaveTimer = setInterval(autoSave, 5 * 60 * 1000); });
|
||||
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));
|
||||
useAutoSave(dirty, saving, doAutoSave);
|
||||
useEditorGuards(dirty, save);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -710,18 +637,13 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
</teleport>
|
||||
|
||||
<!-- Delete confirmation -->
|
||||
<teleport to="body">
|
||||
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
|
||||
<div class="modal-card" @click.stop>
|
||||
<h3 class="modal-title">Delete Task</h3>
|
||||
<p class="modal-message">Are you sure you want to delete this task? This cannot be undone.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-btn" @click="showDeleteConfirm = false">Cancel</button>
|
||||
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
<ConfirmDialog
|
||||
v-if="showDeleteConfirm"
|
||||
title="Delete Task"
|
||||
message="Are you sure you want to delete this task? This cannot be undone."
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -777,66 +699,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
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-search-wrapper { position: relative; }
|
||||
.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);
|
||||
overflow-y: visible;
|
||||
}
|
||||
.sidebar-toggle { display: block; }
|
||||
.sidebar-content {
|
||||
display: none;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.sidebar-content.sidebar-open { display: flex; }
|
||||
}
|
||||
</style>
|
||||
@@ -315,6 +315,7 @@ const subTaskProgress = computed(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/viewer-shared.css" />
|
||||
<style scoped>
|
||||
.viewer-layout {
|
||||
display: flex;
|
||||
@@ -380,48 +381,6 @@ const subTaskProgress = computed(() => {
|
||||
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 {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import type { Task, TaskStatus } from "@/types/task";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
|
||||
import SearchBar from "@/components/SearchBar.vue";
|
||||
import TaskCard from "@/components/TaskCard.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
@@ -16,33 +17,11 @@ const router = useRouter();
|
||||
const store = useTasksStore();
|
||||
|
||||
const searchBarRef = ref<{ focus: () => void } | null>(null);
|
||||
const activeIndex = ref(-1);
|
||||
|
||||
function onFocusSearch() {
|
||||
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>(
|
||||
(localStorage.getItem("fabled-tasks-view-mode") as ViewMode) ?? "flat"
|
||||
);
|
||||
@@ -60,6 +39,14 @@ function setViewMode(mode: ViewMode) {
|
||||
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
|
||||
const projectMap = ref<Map<number, string>>(new Map());
|
||||
|
||||
@@ -119,12 +106,10 @@ onMounted(async () => {
|
||||
}
|
||||
await Promise.all([store.refresh(), loadProjects()]);
|
||||
document.addEventListener("shortcut:focus-search", onFocusSearch);
|
||||
document.addEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("shortcut:focus-search", onFocusSearch);
|
||||
document.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
||||
watch(
|
||||
|
||||
@@ -16,6 +16,9 @@ class Base(DeclarativeBase):
|
||||
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.conversation import Conversation, Message # noqa: E402, F401
|
||||
from fabledassistant.models.setting import Setting # noqa: E402, F401
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -1,14 +1,13 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
|
||||
from sqlalchemy import ForeignKey, Index, Integer, Text
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from fabledassistant.models import Base
|
||||
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin
|
||||
|
||||
|
||||
class Conversation(Base):
|
||||
class Conversation(Base, TimestampMixin):
|
||||
__tablename__ = "conversations"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
@@ -17,14 +16,6 @@ class Conversation(Base):
|
||||
)
|
||||
title: 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(
|
||||
back_populates="conversation",
|
||||
@@ -53,7 +44,7 @@ class Conversation(Base):
|
||||
}
|
||||
|
||||
|
||||
class Message(Base):
|
||||
class Message(Base, CreatedAtMixin):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
@@ -67,9 +58,6 @@ class Message(Base):
|
||||
Integer, ForeignKey("notes.id", ondelete="SET NULL"), 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")
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy import ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
from fabledassistant.models.base import TimestampMixin
|
||||
|
||||
|
||||
class Milestone(Base):
|
||||
class Milestone(Base, TimestampMixin):
|
||||
__tablename__ = "milestones"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
@@ -16,14 +15,6 @@ class Milestone(Base):
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(Text, default="active")
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
from fabledassistant.models.base import TimestampMixin
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
@@ -21,7 +22,7 @@ class TaskPriority(str, enum.Enum):
|
||||
high = "high"
|
||||
|
||||
|
||||
class Note(Base):
|
||||
class Note(Base, TimestampMixin):
|
||||
__tablename__ = "notes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
@@ -43,14 +44,6 @@ class Note(Base):
|
||||
status: 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)
|
||||
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__ = (
|
||||
Index("ix_notes_tags", "tags", postgresql_using="gin"),
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy import ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
from fabledassistant.models.base import TimestampMixin
|
||||
|
||||
|
||||
class NoteDraft(Base):
|
||||
class NoteDraft(Base, TimestampMixin):
|
||||
__tablename__ = "note_drafts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
@@ -16,14 +15,6 @@ class NoteDraft(Base):
|
||||
original_body: Mapped[str] = mapped_column(Text)
|
||||
instruction: Mapped[str] = mapped_column(Text, default="")
|
||||
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:
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import ARRAY, DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy import ARRAY, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
from fabledassistant.models.base import CreatedAtMixin
|
||||
|
||||
|
||||
class NoteVersion(Base):
|
||||
class NoteVersion(Base, CreatedAtMixin):
|
||||
__tablename__ = "note_versions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
@@ -15,9 +14,6 @@ class NoteVersion(Base):
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
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:
|
||||
d: dict = {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy import ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from fabledassistant.models import Base
|
||||
from fabledassistant.models.base import TimestampMixin
|
||||
|
||||
|
||||
class ProjectStatus(str, enum.Enum):
|
||||
@@ -11,7 +11,7 @@ class ProjectStatus(str, enum.Enum):
|
||||
archived = "archived"
|
||||
|
||||
|
||||
class Project(Base):
|
||||
class Project(Base, TimestampMixin):
|
||||
__tablename__ = "projects"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
|
||||
@@ -20,8 +20,6 @@ class Project(Base):
|
||||
goal: Mapped[str] = mapped_column(Text, default="")
|
||||
status: Mapped[str] = mapped_column(Text, default="active")
|
||||
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from fabledassistant.models import Base
|
||||
from fabledassistant.models.base import CreatedAtMixin
|
||||
|
||||
|
||||
class PushSubscription(Base):
|
||||
class PushSubscription(Base, CreatedAtMixin):
|
||||
__tablename__ = "push_subscriptions"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
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)
|
||||
auth: Mapped[str] = mapped_column(Text)
|
||||
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
last_used: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy import ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
from fabledassistant.models.base import TimestampMixin
|
||||
|
||||
|
||||
class TaskLog(Base):
|
||||
class TaskLog(Base, TimestampMixin):
|
||||
__tablename__ = "task_logs"
|
||||
|
||||
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"))
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
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:
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, Index, Text
|
||||
from sqlalchemy import Index, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
from fabledassistant.models.base import CreatedAtMixin
|
||||
|
||||
|
||||
class User(Base):
|
||||
class User(Base, CreatedAtMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
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)
|
||||
oauth_sub: Mapped[str | None] = mapped_column(Text, unique=True, nullable=True)
|
||||
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__ = (
|
||||
Index("ix_users_username", "username"),
|
||||
|
||||
@@ -6,6 +6,7 @@ import httpx
|
||||
from quart import Blueprint, Response, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.routes.utils import not_found
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.chat import (
|
||||
add_message,
|
||||
@@ -61,7 +62,7 @@ async def get_conversation_route(conv_id: int):
|
||||
uid = get_current_user_id()
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
return not_found("Conversation")
|
||||
result = conv.to_dict()
|
||||
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 {}
|
||||
@@ -80,7 +81,7 @@ async def delete_conversation_route(conv_id: int):
|
||||
uid = get_current_user_id()
|
||||
deleted = await delete_conversation(uid, conv_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
return not_found("Conversation")
|
||||
return "", 204
|
||||
|
||||
|
||||
@@ -95,7 +96,7 @@ async def update_conversation_route(conv_id: int):
|
||||
return jsonify({"error": "title or model is required"}), 400
|
||||
conv = await update_conversation(uid, conv_id, title=title, model=model)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
return not_found("Conversation")
|
||||
return jsonify(conv.to_dict())
|
||||
|
||||
|
||||
@@ -106,7 +107,7 @@ async def send_message_route(conv_id: int):
|
||||
uid = get_current_user_id()
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
return not_found("Conversation")
|
||||
|
||||
data = await request.get_json()
|
||||
content = data.get("content", "").strip()
|
||||
@@ -166,7 +167,7 @@ async def generation_stream_route(conv_id: int):
|
||||
uid = get_current_user_id()
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
return not_found("Conversation")
|
||||
|
||||
buf = get_buffer(conv_id)
|
||||
if buf is None:
|
||||
@@ -219,7 +220,7 @@ async def confirm_generation_route(conv_id: int):
|
||||
uid = get_current_user_id()
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
return not_found("Conversation")
|
||||
|
||||
buf = get_buffer(conv_id)
|
||||
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()
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
return not_found("Conversation")
|
||||
|
||||
buf = get_buffer(conv_id)
|
||||
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()
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
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
|
||||
try:
|
||||
note = await summarize_conversation_as_note(uid, conv_id, model)
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.routes.utils import not_found
|
||||
from fabledassistant.services.milestones import (
|
||||
create_milestone,
|
||||
delete_milestone,
|
||||
@@ -20,21 +21,22 @@ logger = logging.getLogger(__name__)
|
||||
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"])
|
||||
@login_required
|
||||
async def list_milestones_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
project = await get_project(uid, project_id)
|
||||
if project is None:
|
||||
return jsonify({"error": "Project not found"}), 404
|
||||
return not_found("Project")
|
||||
status = request.args.get("status")
|
||||
milestones = await list_milestones(uid, project_id, status=status)
|
||||
result = []
|
||||
for m in milestones:
|
||||
entry = m.to_dict()
|
||||
progress = await get_milestone_progress(m.id)
|
||||
entry.update(progress)
|
||||
result.append(entry)
|
||||
result = [await _milestone_dict(m) for m in milestones]
|
||||
return jsonify({"milestones": result})
|
||||
|
||||
|
||||
@@ -44,7 +46,7 @@ async def create_milestone_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
project = await get_project(uid, project_id)
|
||||
if project is None:
|
||||
return jsonify({"error": "Project not found"}), 404
|
||||
return not_found("Project")
|
||||
data = await request.get_json()
|
||||
if not data.get("title"):
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
@@ -55,9 +57,7 @@ async def create_milestone_route(project_id: int):
|
||||
description=data.get("description"),
|
||||
order_index=data.get("order_index", 0),
|
||||
)
|
||||
entry = milestone.to_dict()
|
||||
entry.update(await get_milestone_progress(milestone.id))
|
||||
return jsonify(entry), 201
|
||||
return jsonify(await _milestone_dict(milestone)), 201
|
||||
|
||||
|
||||
@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()
|
||||
milestone = await get_milestone(uid, milestone_id)
|
||||
if milestone is None or milestone.project_id != project_id:
|
||||
return jsonify({"error": "Milestone not found"}), 404
|
||||
entry = milestone.to_dict()
|
||||
entry.update(await get_milestone_progress(milestone.id))
|
||||
return jsonify(entry)
|
||||
return not_found("Milestone")
|
||||
return jsonify(await _milestone_dict(milestone))
|
||||
|
||||
|
||||
@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()
|
||||
milestone = await get_milestone(uid, milestone_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()
|
||||
allowed = {"title", "description", "status", "order_index"}
|
||||
fields = {k: v for k, v in data.items() if k in allowed}
|
||||
updated = await update_milestone(uid, milestone_id, **fields)
|
||||
if updated is None:
|
||||
return jsonify({"error": "Milestone not found"}), 404
|
||||
entry = updated.to_dict()
|
||||
entry.update(await get_milestone_progress(updated.id))
|
||||
return jsonify(entry)
|
||||
return not_found("Milestone")
|
||||
return jsonify(await _milestone_dict(updated))
|
||||
|
||||
|
||||
@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()
|
||||
milestone = await get_milestone(uid, milestone_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)
|
||||
if not deleted:
|
||||
return jsonify({"error": "Milestone not found"}), 404
|
||||
return not_found("Milestone")
|
||||
return "", 204
|
||||
|
||||
|
||||
@@ -109,7 +105,7 @@ async def get_milestone_tasks_route(project_id: int, milestone_id: int):
|
||||
uid = get_current_user_id()
|
||||
milestone = await get_milestone(uid, milestone_id)
|
||||
if milestone is None or milestone.project_id != project_id:
|
||||
return jsonify({"error": "Milestone not found"}), 404
|
||||
return not_found("Milestone")
|
||||
status_filter = request.args.get("status")
|
||||
limit = min(request.args.get("limit", 100, type=int), 500)
|
||||
offset = request.args.get("offset", 0, type=int)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
from fabledassistant.services.embeddings import upsert_note_embedding
|
||||
|
||||
from quart import Blueprint, Response, jsonify, request
|
||||
|
||||
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.services.assist import build_assist_messages
|
||||
from fabledassistant.services.generation_buffer import (
|
||||
@@ -87,12 +87,9 @@ async def create_note_route():
|
||||
# Optional task fields
|
||||
status = data.get("status")
|
||||
priority = data.get("priority")
|
||||
due_date = None
|
||||
if data.get("due_date"):
|
||||
try:
|
||||
due_date = date.fromisoformat(data["due_date"])
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
|
||||
due_date = parse_iso_date(data.get("due_date"), "due_date")
|
||||
if isinstance(due_date, tuple):
|
||||
return due_date
|
||||
|
||||
note = await create_note(
|
||||
uid,
|
||||
@@ -144,7 +141,7 @@ async def append_tag_route(note_id: int):
|
||||
|
||||
note = await get_note(uid, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "Note not found"}), 404
|
||||
return not_found("Note")
|
||||
|
||||
existing = list(note.tags or [])
|
||||
if tag not in existing:
|
||||
@@ -162,7 +159,7 @@ async def get_note_by_title_route():
|
||||
return jsonify({"error": "title parameter is required"}), 400
|
||||
note = await get_note_by_title(uid, title)
|
||||
if note is None:
|
||||
return jsonify({"error": "Note not found"}), 404
|
||||
return not_found("Note")
|
||||
return jsonify(note.to_dict())
|
||||
|
||||
|
||||
@@ -184,7 +181,7 @@ async def get_note_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
note = await get_note(uid, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "Note not found"}), 404
|
||||
return not_found("Note")
|
||||
return jsonify(note.to_dict())
|
||||
|
||||
|
||||
@@ -200,10 +197,10 @@ async def update_note_route(note_id: int):
|
||||
|
||||
if "due_date" in data:
|
||||
if data["due_date"]:
|
||||
try:
|
||||
fields["due_date"] = date.fromisoformat(data["due_date"])
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
|
||||
result = parse_iso_date(data["due_date"], "due_date")
|
||||
if isinstance(result, tuple):
|
||||
return result
|
||||
fields["due_date"] = result
|
||||
else:
|
||||
fields["due_date"] = None
|
||||
|
||||
@@ -211,7 +208,7 @@ async def update_note_route(note_id: int):
|
||||
fields["tags"] = data["tags"]
|
||||
note = await update_note(uid, note_id, **fields)
|
||||
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 "")
|
||||
if 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:
|
||||
fields[key] = data[key]
|
||||
if "due_date" in data:
|
||||
try:
|
||||
fields["due_date"] = date.fromisoformat(data["due_date"]) if data["due_date"] else None
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "Invalid due_date format, expected YYYY-MM-DD"}), 400
|
||||
result = parse_iso_date(data.get("due_date"), "due_date")
|
||||
if isinstance(result, tuple):
|
||||
return result
|
||||
fields["due_date"] = result
|
||||
if "tags" in data:
|
||||
fields["tags"] = data["tags"]
|
||||
note = await update_note(uid, note_id, **fields)
|
||||
if note is None:
|
||||
return jsonify({"error": "Note not found"}), 404
|
||||
return not_found("Note")
|
||||
return jsonify(note.to_dict())
|
||||
|
||||
|
||||
@@ -246,7 +243,7 @@ async def delete_note_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
deleted = await delete_note(uid, note_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "Note not found"}), 404
|
||||
return not_found("Note")
|
||||
return "", 204
|
||||
|
||||
|
||||
@@ -474,7 +471,7 @@ async def upsert_draft_route(note_id: int):
|
||||
# Verify note ownership
|
||||
note = await get_note(uid, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "Note not found"}), 404
|
||||
return not_found("Note")
|
||||
data = await request.get_json()
|
||||
draft = await upsert_draft(
|
||||
user_id=uid,
|
||||
@@ -511,5 +508,5 @@ async def get_version_route(note_id: int, version_id: int):
|
||||
uid = get_current_user_id()
|
||||
version = await get_version(uid, note_id, version_id)
|
||||
if version is None:
|
||||
return jsonify({"error": "Version not found"}), 404
|
||||
return not_found("Version")
|
||||
return jsonify(version.to_dict(include_body=True))
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
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.notes import list_notes
|
||||
from fabledassistant.services.projects import (
|
||||
@@ -52,7 +53,7 @@ async def get_project_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
project = await get_project(uid, project_id)
|
||||
if project is None:
|
||||
return jsonify({"error": "Project not found"}), 404
|
||||
return not_found("Project")
|
||||
summary = await get_project_summary(uid, project_id)
|
||||
data = project.to_dict()
|
||||
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}
|
||||
project = await update_project(uid, project_id, **fields)
|
||||
if project is None:
|
||||
return jsonify({"error": "Project not found"}), 404
|
||||
return not_found("Project")
|
||||
return jsonify(project.to_dict())
|
||||
|
||||
|
||||
@@ -78,7 +79,7 @@ async def delete_project_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
deleted = await delete_project(uid, project_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "Project not found"}), 404
|
||||
return not_found("Project")
|
||||
return "", 204
|
||||
|
||||
|
||||
@@ -88,7 +89,7 @@ async def get_project_notes_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
project = await get_project(uid, project_id)
|
||||
if project is None:
|
||||
return jsonify({"error": "Project not found"}), 404
|
||||
return not_found("Project")
|
||||
|
||||
# type filter: "note", "task", or None (both)
|
||||
type_filter = request.args.get("type")
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from datetime import date
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.models.note import TaskPriority, TaskStatus
|
||||
from fabledassistant.routes.utils import not_found, parse_iso_date
|
||||
from fabledassistant.services.notes import (
|
||||
create_note,
|
||||
delete_note,
|
||||
@@ -28,13 +27,12 @@ async def list_tasks_route():
|
||||
limit = min(request.args.get("limit", 50, type=int), 500)
|
||||
offset = request.args.get("offset", 0, type=int)
|
||||
|
||||
due_before_str = request.args.get("due_before")
|
||||
due_after_str = request.args.get("due_after")
|
||||
try:
|
||||
due_before = date.fromisoformat(due_before_str) if due_before_str else None
|
||||
due_after = date.fromisoformat(due_after_str) if due_after_str else None
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
|
||||
due_before = parse_iso_date(request.args.get("due_before"), "due_before")
|
||||
if isinstance(due_before, tuple):
|
||||
return due_before
|
||||
due_after = parse_iso_date(request.args.get("due_after"), "due_after")
|
||||
if isinstance(due_after, tuple):
|
||||
return due_after
|
||||
|
||||
tasks, total = await list_notes(
|
||||
uid,
|
||||
@@ -61,12 +59,9 @@ async def create_task_route():
|
||||
body = data.get("body", "") or data.get("description", "")
|
||||
tags = data.get("tags", [])
|
||||
|
||||
due_date = None
|
||||
if data.get("due_date"):
|
||||
try:
|
||||
due_date = date.fromisoformat(data["due_date"])
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
|
||||
due_date = parse_iso_date(data.get("due_date"), "due_date")
|
||||
if isinstance(due_date, tuple):
|
||||
return due_date
|
||||
|
||||
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
|
||||
priority = (
|
||||
@@ -94,7 +89,7 @@ async def get_task_route(task_id: int):
|
||||
uid = get_current_user_id()
|
||||
task = await get_note(uid, task_id)
|
||||
if task is None:
|
||||
return jsonify({"error": "Task not found"}), 404
|
||||
return not_found("Task")
|
||||
data = task.to_dict()
|
||||
if 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 data["due_date"]:
|
||||
try:
|
||||
fields["due_date"] = date.fromisoformat(data["due_date"])
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
|
||||
result = parse_iso_date(data["due_date"], "due_date")
|
||||
if isinstance(result, tuple):
|
||||
return result
|
||||
fields["due_date"] = result
|
||||
else:
|
||||
fields["due_date"] = None
|
||||
|
||||
@@ -136,7 +131,7 @@ async def update_task_route(task_id: int):
|
||||
|
||||
task = await update_note(uid, task_id, **fields)
|
||||
if task is None:
|
||||
return jsonify({"error": "Task not found"}), 404
|
||||
return not_found("Task")
|
||||
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)
|
||||
if task is None:
|
||||
return jsonify({"error": "Task not found"}), 404
|
||||
return not_found("Task")
|
||||
return jsonify(task.to_dict())
|
||||
|
||||
|
||||
@@ -166,5 +161,5 @@ async def delete_task_route(task_id: int):
|
||||
uid = get_current_user_id()
|
||||
deleted = await delete_note(uid, task_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "Task not found"}), 404
|
||||
return not_found("Task")
|
||||
return "", 204
|
||||
|
||||
@@ -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
@@ -12,6 +12,30 @@
|
||||
> Include file-level details in the commit body when the change is non-trivial.
|
||||
|
||||
## 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.
|
||||
|
||||
**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.
|
||||
|
||||
Reference in New Issue
Block a user