feat(knowledge): note types, counts, new-note button, audit fixes
- Add note_type (note/person/place/list) selector + entity metadata fields (relationship, email, phone / address, hours) to NoteEditorView - Pre-select type via ?type= query param from KnowledgeView new-note dropdown - KnowledgeView: add split "New note / ▾" button with type dropdown - KnowledgeView: show per-type counts on sidebar filter buttons (when > 1) - Fix: filter-btn now flex layout so count badge aligns to right edge - Fix: list_notes count_query was missing parent_id filter (inflated totals) - Fix: PATCH /api/notes/:id now fires upsert_note_embedding (workspace autosave) - Fix: get_knowledge_counts endpoint for per-type counts - Fix: get_knowledge_tags was silently discarding note_type filter (double-stmt bug) - Fix: NoteEditorView onMounted stray brace from edit session Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,8 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
project_id?: number | null;
|
project_id?: number | null;
|
||||||
milestone_id?: number | null;
|
milestone_id?: number | null;
|
||||||
|
note_type?: string;
|
||||||
|
metadata?: Record<string, string> | null;
|
||||||
}): Promise<Note> {
|
}): Promise<Note> {
|
||||||
try {
|
try {
|
||||||
return await apiPost<Note>("/api/notes", data);
|
return await apiPost<Note>("/api/notes", data);
|
||||||
@@ -91,7 +93,7 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
|
|
||||||
async function updateNote(
|
async function updateNote(
|
||||||
id: number,
|
id: number,
|
||||||
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id">>
|
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type" | "metadata">>
|
||||||
): Promise<Note> {
|
): Promise<Note> {
|
||||||
try {
|
try {
|
||||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||||
|
export type NoteType = "note" | "person" | "place" | "list";
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -18,6 +19,8 @@ export interface Note {
|
|||||||
recurrence_rule: Record<string, unknown> | null;
|
recurrence_rule: Record<string, unknown> | null;
|
||||||
recurrence_next_spawn_at: string | null;
|
recurrence_next_spawn_at: string | null;
|
||||||
is_task: boolean;
|
is_task: boolean;
|
||||||
|
note_type: NoteType;
|
||||||
|
metadata: Record<string, string>;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,28 @@ const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
|||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// ─── Type counts ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface KnowledgeCounts { note: number; person: number; place: number; list: number; total: number }
|
||||||
|
const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, total: 0 });
|
||||||
|
|
||||||
|
async function fetchCounts() {
|
||||||
|
try {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (activeTag.value) p.set("tags", activeTag.value);
|
||||||
|
typeCounts.value = await apiGet<KnowledgeCounts>(`/api/knowledge/counts?${p}`);
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── New note dropdown ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const newNoteMenuOpen = ref(false);
|
||||||
|
|
||||||
|
function createNew(type: string) {
|
||||||
|
newNoteMenuOpen.value = false;
|
||||||
|
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Two-tier pagination ──────────────────────────────────────────────────────
|
// ─── Two-tier pagination ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ID_BATCH = 100; // IDs fetched per server round-trip
|
const ID_BATCH = 100; // IDs fetched per server round-trip
|
||||||
@@ -156,7 +178,8 @@ function onSearchInput() {
|
|||||||
searchDebounce = setTimeout(() => resetAndReobserve(), 380);
|
searchDebounce = setTimeout(() => resetAndReobserve(), 380);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([activeType, activeTag, sortMode], () => resetAndReobserve());
|
watch([activeType, sortMode], () => resetAndReobserve());
|
||||||
|
watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
|
||||||
|
|
||||||
// ─── Today bar ────────────────────────────────────────────────────────────────
|
// ─── Today bar ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -323,6 +346,7 @@ function setupObserver() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await reset();
|
await reset();
|
||||||
fetchTags();
|
fetchTags();
|
||||||
|
fetchCounts();
|
||||||
fetchTodayBar();
|
fetchTodayBar();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
setupObserver();
|
setupObserver();
|
||||||
@@ -366,15 +390,38 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Filter panel -->
|
<!-- Filter panel -->
|
||||||
<aside class="filter-panel">
|
<aside class="filter-panel">
|
||||||
|
<!-- New note button -->
|
||||||
|
<div class="new-note-wrap">
|
||||||
|
<button class="btn-new-note" @click="createNew('note')">+ New note</button>
|
||||||
|
<button class="btn-new-chevron" @click="newNoteMenuOpen = !newNoteMenuOpen" :class="{ open: newNoteMenuOpen }" title="Create specific type">▾</button>
|
||||||
|
<div v-if="newNoteMenuOpen" class="new-note-menu">
|
||||||
|
<button @click="createNew('note')">Note</button>
|
||||||
|
<button @click="createNew('person')">Person</button>
|
||||||
|
<button @click="createNew('place')">Place</button>
|
||||||
|
<button @click="createNew('list')">List</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<div class="filter-label">Type</div>
|
<div class="filter-label">Type</div>
|
||||||
<button
|
<button
|
||||||
v-for="t in ([['', 'All'], ['note', 'Notes'], ['person', 'People'], ['place', 'Places'], ['list', 'Lists']] as [string, string][])"
|
|
||||||
:key="t[0]"
|
|
||||||
class="filter-btn"
|
class="filter-btn"
|
||||||
:class="{ active: activeType === t[0] }"
|
:class="{ active: activeType === '' }"
|
||||||
@click="activeType = (t[0] as '' | 'note' | 'person' | 'place' | 'list')"
|
@click="activeType = ''"
|
||||||
>{{ t[1] }}</button>
|
>
|
||||||
|
<span class="filter-btn-label">All</span>
|
||||||
|
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="[val, label, key] in ([['note','Notes','note'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
|
||||||
|
:key="val"
|
||||||
|
class="filter-btn"
|
||||||
|
:class="{ active: activeType === val }"
|
||||||
|
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list')"
|
||||||
|
>
|
||||||
|
<span class="filter-btn-label">{{ label }}</span>
|
||||||
|
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="allTags.length > 0" class="filter-section">
|
<div v-if="allTags.length > 0" class="filter-section">
|
||||||
@@ -664,9 +711,71 @@ onUnmounted(() => {
|
|||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
.filter-btn {
|
/* ── New note button ─────────────────────────────────────── */
|
||||||
|
.new-note-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.btn-new-note {
|
||||||
|
flex: 1;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.4);
|
||||||
|
border-right: none;
|
||||||
|
background: rgba(99, 102, 241, 0.12);
|
||||||
|
color: var(--color-primary, #818cf8);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-new-note:hover { background: rgba(99, 102, 241, 0.2); }
|
||||||
|
.btn-new-chevron {
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.4);
|
||||||
|
background: rgba(99, 102, 241, 0.12);
|
||||||
|
color: var(--color-primary, #818cf8);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: background 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
.btn-new-chevron:hover { background: rgba(99, 102, 241, 0.2); }
|
||||||
|
.btn-new-chevron.open { transform: scaleY(-1); }
|
||||||
|
.new-note-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--color-bg-tertiary, #1a1b1e);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 50;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.new-note-menu button {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: 7px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.new-note-menu button:hover { background: rgba(99, 102, 241, 0.12); }
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
@@ -685,6 +794,22 @@ onUnmounted(() => {
|
|||||||
color: var(--color-primary, #818cf8);
|
color: var(--color-primary, #818cf8);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
.filter-btn-label { flex: 1; }
|
||||||
|
.filter-count {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255,255,255,0.07);
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.filter-btn.active .filter-count {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
color: var(--color-primary, #818cf8);
|
||||||
|
}
|
||||||
.filter-tag { font-size: 0.78rem; }
|
.filter-tag { font-size: 0.78rem; }
|
||||||
|
|
||||||
/* ── Content area ────────────────────────────────────────── */
|
/* ── Content area ────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
import { ref, reactive, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import type { NoteType } from "@/types/note";
|
||||||
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";
|
||||||
@@ -31,6 +32,8 @@ const body = ref("");
|
|||||||
const tags = ref<string[]>([]);
|
const tags = ref<string[]>([]);
|
||||||
const projectId = ref<number | null>(null);
|
const projectId = ref<number | null>(null);
|
||||||
const milestoneId = ref<number | null>(null);
|
const milestoneId = ref<number | null>(null);
|
||||||
|
const noteType = ref<NoteType>("note");
|
||||||
|
const entityMeta = reactive<Record<string, string>>({});
|
||||||
const dirty = ref(false);
|
const dirty = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const showPreview = ref(false);
|
const showPreview = ref(false);
|
||||||
@@ -186,6 +189,8 @@ let savedBody = "";
|
|||||||
let savedTags: string[] = [];
|
let savedTags: string[] = [];
|
||||||
let savedProjectId: number | null = null;
|
let savedProjectId: number | null = null;
|
||||||
let savedMilestoneId: number | null = null;
|
let savedMilestoneId: number | null = null;
|
||||||
|
let savedNoteType: NoteType = "note";
|
||||||
|
let savedEntityMeta: Record<string, string> = {};
|
||||||
|
|
||||||
function markDirty() {
|
function markDirty() {
|
||||||
dirty.value =
|
dirty.value =
|
||||||
@@ -193,7 +198,9 @@ function markDirty() {
|
|||||||
body.value !== savedBody ||
|
body.value !== savedBody ||
|
||||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||||
projectId.value !== savedProjectId ||
|
projectId.value !== savedProjectId ||
|
||||||
milestoneId.value !== savedMilestoneId;
|
milestoneId.value !== savedMilestoneId ||
|
||||||
|
noteType.value !== savedNoteType ||
|
||||||
|
JSON.stringify(entityMeta) !== JSON.stringify(savedEntityMeta);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBodyUpdate(newVal: string) {
|
function onBodyUpdate(newVal: string) {
|
||||||
@@ -210,24 +217,34 @@ onMounted(async () => {
|
|||||||
tags.value = [...(store.currentNote.tags || [])];
|
tags.value = [...(store.currentNote.tags || [])];
|
||||||
projectId.value = store.currentNote.project_id ?? null;
|
projectId.value = store.currentNote.project_id ?? null;
|
||||||
milestoneId.value = store.currentNote.milestone_id ?? null;
|
milestoneId.value = store.currentNote.milestone_id ?? null;
|
||||||
|
noteType.value = (store.currentNote.note_type as NoteType) || "note";
|
||||||
|
Object.assign(entityMeta, store.currentNote.metadata || {});
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
savedTags = [...tags.value];
|
savedTags = [...tags.value];
|
||||||
savedProjectId = projectId.value;
|
savedProjectId = projectId.value;
|
||||||
savedMilestoneId = milestoneId.value;
|
savedMilestoneId = milestoneId.value;
|
||||||
|
savedNoteType = noteType.value;
|
||||||
|
savedEntityMeta = { ...entityMeta };
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
// Restore pending draft if any
|
// New note: read type from query param
|
||||||
try {
|
const qt = route.query.type as string | undefined;
|
||||||
const draft = await apiGet<NoteDraft>(`/api/notes/${noteId.value}/draft`);
|
if (qt && ["note", "person", "place", "list"].includes(qt)) {
|
||||||
if (draft) assist.loadDraft(draft);
|
noteType.value = qt as NoteType;
|
||||||
} catch {
|
|
||||||
// No draft — normal
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial link suggestions
|
|
||||||
fetchLinkSuggestions();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore pending draft if any
|
||||||
|
try {
|
||||||
|
const draft = await apiGet<NoteDraft>(`/api/notes/${noteId.value}/draft`);
|
||||||
|
if (draft) assist.loadDraft(draft);
|
||||||
|
} catch {
|
||||||
|
// No draft — normal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial link suggestions
|
||||||
|
fetchLinkSuggestions();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -241,12 +258,16 @@ async function save() {
|
|||||||
tags: tags.value,
|
tags: tags.value,
|
||||||
project_id: projectId.value,
|
project_id: projectId.value,
|
||||||
milestone_id: milestoneId.value,
|
milestone_id: milestoneId.value,
|
||||||
|
note_type: noteType.value,
|
||||||
|
metadata: { ...entityMeta },
|
||||||
});
|
});
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
savedTags = [...tags.value];
|
savedTags = [...tags.value];
|
||||||
savedProjectId = projectId.value;
|
savedProjectId = projectId.value;
|
||||||
savedMilestoneId = milestoneId.value;
|
savedMilestoneId = milestoneId.value;
|
||||||
|
savedNoteType = noteType.value;
|
||||||
|
savedEntityMeta = { ...entityMeta };
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Note saved");
|
toast.show("Note saved");
|
||||||
} else {
|
} else {
|
||||||
@@ -256,6 +277,8 @@ async function save() {
|
|||||||
tags: tags.value,
|
tags: tags.value,
|
||||||
project_id: projectId.value,
|
project_id: projectId.value,
|
||||||
milestone_id: milestoneId.value,
|
milestone_id: milestoneId.value,
|
||||||
|
note_type: noteType.value,
|
||||||
|
metadata: { ...entityMeta },
|
||||||
});
|
});
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Note created");
|
toast.show("Note created");
|
||||||
@@ -295,12 +318,15 @@ async function doAutoSave() {
|
|||||||
await store.updateNote(noteId.value!, {
|
await store.updateNote(noteId.value!, {
|
||||||
title: title.value, body: body.value, tags: tags.value,
|
title: title.value, body: body.value, tags: tags.value,
|
||||||
project_id: projectId.value, milestone_id: milestoneId.value,
|
project_id: projectId.value, milestone_id: milestoneId.value,
|
||||||
} as Record<string, unknown>);
|
note_type: noteType.value, metadata: { ...entityMeta },
|
||||||
|
});
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
savedTags = [...tags.value];
|
savedTags = [...tags.value];
|
||||||
savedProjectId = projectId.value;
|
savedProjectId = projectId.value;
|
||||||
savedMilestoneId = milestoneId.value;
|
savedMilestoneId = milestoneId.value;
|
||||||
|
savedNoteType = noteType.value;
|
||||||
|
savedEntityMeta = { ...entityMeta };
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Auto-saved");
|
toast.show("Auto-saved");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -439,6 +465,49 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Note type -->
|
||||||
|
<div class="sb-field">
|
||||||
|
<label class="sb-label">Type</label>
|
||||||
|
<select v-model="noteType" class="sb-select" @change="markDirty">
|
||||||
|
<option value="note">Note</option>
|
||||||
|
<option value="person">Person</option>
|
||||||
|
<option value="place">Place</option>
|
||||||
|
<option value="list">List</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Person metadata -->
|
||||||
|
<template v-if="noteType === 'person'">
|
||||||
|
<div class="sb-field">
|
||||||
|
<label class="sb-label">Relationship</label>
|
||||||
|
<input class="sb-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague" @input="markDirty" />
|
||||||
|
</div>
|
||||||
|
<div class="sb-field">
|
||||||
|
<label class="sb-label">Email</label>
|
||||||
|
<input class="sb-input" v-model="entityMeta.email" type="email" placeholder="email@example.com" @input="markDirty" />
|
||||||
|
</div>
|
||||||
|
<div class="sb-field">
|
||||||
|
<label class="sb-label">Phone</label>
|
||||||
|
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Place metadata -->
|
||||||
|
<template v-if="noteType === 'place'">
|
||||||
|
<div class="sb-field">
|
||||||
|
<label class="sb-label">Address</label>
|
||||||
|
<input class="sb-input" v-model="entityMeta.address" placeholder="Street, City" @input="markDirty" />
|
||||||
|
</div>
|
||||||
|
<div class="sb-field">
|
||||||
|
<label class="sb-label">Phone</label>
|
||||||
|
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
|
||||||
|
</div>
|
||||||
|
<div class="sb-field">
|
||||||
|
<label class="sb-label">Hours</label>
|
||||||
|
<input class="sb-input" v-model="entityMeta.hours" placeholder="e.g. Mon–Fri 9–5" @input="markDirty" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Link Suggestions -->
|
<!-- Link Suggestions -->
|
||||||
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
|
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
|
||||||
<div class="sb-label-row">
|
<div class="sb-label-row">
|
||||||
@@ -649,6 +718,22 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sb-select, .sb-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--color-input-border, rgba(255,255,255,0.12));
|
||||||
|
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.sb-select:focus, .sb-input:focus {
|
||||||
|
border-color: var(--color-primary, #6366f1);
|
||||||
|
}
|
||||||
|
|
||||||
/* Tag suggest row inside sidebar */
|
/* Tag suggest row inside sidebar */
|
||||||
.tag-suggest-row {
|
.tag-suggest-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -129,3 +129,16 @@ async def list_knowledge_tags():
|
|||||||
from fabledassistant.services.knowledge import get_knowledge_tags
|
from fabledassistant.services.knowledge import get_knowledge_tags
|
||||||
tags = await get_knowledge_tags(uid, note_type=note_type)
|
tags = await get_knowledge_tags(uid, note_type=note_type)
|
||||||
return jsonify({"tags": tags})
|
return jsonify({"tags": tags})
|
||||||
|
|
||||||
|
|
||||||
|
@knowledge_bp.route("/counts", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
async def get_knowledge_counts():
|
||||||
|
"""Return per-type counts — used by the sidebar to show item counts."""
|
||||||
|
uid = get_current_user_id()
|
||||||
|
tags_raw = request.args.get("tags", "").strip()
|
||||||
|
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else None
|
||||||
|
|
||||||
|
from fabledassistant.services.knowledge import get_knowledge_counts as _counts
|
||||||
|
counts = await _counts(uid, tags=tags)
|
||||||
|
return jsonify(counts)
|
||||||
|
|||||||
@@ -104,18 +104,26 @@ async def create_note_route():
|
|||||||
if proj:
|
if proj:
|
||||||
project_id = proj.id
|
project_id = proj.id
|
||||||
|
|
||||||
note = await create_note(
|
note_type = data.get("note_type", "note")
|
||||||
uid,
|
entity_meta = data.get("metadata") or None
|
||||||
title=data.get("title", ""),
|
|
||||||
body=body,
|
try:
|
||||||
tags=tags,
|
note = await create_note(
|
||||||
parent_id=data.get("parent_id"),
|
uid,
|
||||||
project_id=project_id,
|
title=data.get("title", ""),
|
||||||
milestone_id=data.get("milestone_id"),
|
body=body,
|
||||||
status=status,
|
tags=tags,
|
||||||
priority=priority,
|
parent_id=data.get("parent_id"),
|
||||||
due_date=due_date,
|
project_id=project_id,
|
||||||
)
|
milestone_id=data.get("milestone_id"),
|
||||||
|
status=status,
|
||||||
|
priority=priority,
|
||||||
|
due_date=due_date,
|
||||||
|
note_type=note_type,
|
||||||
|
entity_meta=entity_meta,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
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))
|
||||||
@@ -207,9 +215,11 @@ async def update_note_route(note_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
data = await request.get_json()
|
data = await request.get_json()
|
||||||
fields = {}
|
fields = {}
|
||||||
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority"):
|
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
||||||
if key in data:
|
if key in data:
|
||||||
fields[key] = data[key]
|
fields[key] = data[key]
|
||||||
|
if "metadata" in data:
|
||||||
|
fields["entity_meta"] = data["metadata"] or None
|
||||||
|
|
||||||
if "due_date" in data:
|
if "due_date" in data:
|
||||||
if data["due_date"]:
|
if data["due_date"]:
|
||||||
@@ -222,7 +232,10 @@ async def update_note_route(note_id: int):
|
|||||||
|
|
||||||
if "tags" in data:
|
if "tags" in data:
|
||||||
fields["tags"] = data["tags"]
|
fields["tags"] = data["tags"]
|
||||||
note = await update_note(uid, note_id, **fields)
|
try:
|
||||||
|
note = await update_note(uid, note_id, **fields)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
if note is None:
|
if note is None:
|
||||||
return not_found("Note")
|
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 "")
|
||||||
@@ -237,19 +250,30 @@ async def patch_note_route(note_id: int):
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
data = await request.get_json()
|
data = await request.get_json()
|
||||||
fields = {}
|
fields = {}
|
||||||
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority"):
|
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
||||||
if key in data:
|
if key in data:
|
||||||
fields[key] = data[key]
|
fields[key] = data[key]
|
||||||
|
if "metadata" in data:
|
||||||
|
fields["entity_meta"] = data["metadata"] or None
|
||||||
if "due_date" in data:
|
if "due_date" in data:
|
||||||
result = parse_iso_date(data.get("due_date"), "due_date")
|
if data["due_date"]:
|
||||||
if isinstance(result, tuple):
|
result = parse_iso_date(data["due_date"], "due_date")
|
||||||
return result
|
if isinstance(result, tuple):
|
||||||
fields["due_date"] = result
|
return result
|
||||||
|
fields["due_date"] = result
|
||||||
|
else:
|
||||||
|
fields["due_date"] = None
|
||||||
if "tags" in data:
|
if "tags" in data:
|
||||||
fields["tags"] = data["tags"]
|
fields["tags"] = data["tags"]
|
||||||
note = await update_note(uid, note_id, **fields)
|
try:
|
||||||
|
note = await update_note(uid, note_id, **fields)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
if note is None:
|
if note is None:
|
||||||
return not_found("Note")
|
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))
|
||||||
return jsonify(note.to_dict())
|
return jsonify(note.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -141,32 +141,43 @@ async def _semantic_knowledge_search(
|
|||||||
|
|
||||||
async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list[str]:
|
async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list[str]:
|
||||||
"""Return all distinct tags used across knowledge objects for this user."""
|
"""Return all distinct tags used across knowledge objects for this user."""
|
||||||
from sqlalchemy.dialects.postgresql import array
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
stmt = (
|
base = (
|
||||||
select(func.unnest(Note.tags).label("tag"))
|
select(func.unnest(Note.tags).label("tag"))
|
||||||
.where(Note.user_id == user_id)
|
.where(Note.user_id == user_id)
|
||||||
.where(Note.status.is_(None))
|
.where(Note.status.is_(None))
|
||||||
)
|
)
|
||||||
if note_type:
|
if note_type:
|
||||||
stmt = stmt.where(Note.note_type == note_type)
|
base = base.where(Note.note_type == note_type)
|
||||||
else:
|
else:
|
||||||
stmt = stmt.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
||||||
|
stmt = base.distinct().order_by("tag")
|
||||||
stmt = (
|
|
||||||
select(func.unnest(Note.tags).label("tag"))
|
|
||||||
.where(Note.user_id == user_id)
|
|
||||||
.where(Note.status.is_(None))
|
|
||||||
.distinct()
|
|
||||||
.order_by("tag")
|
|
||||||
)
|
|
||||||
if note_type:
|
|
||||||
stmt = stmt.where(Note.note_type == note_type)
|
|
||||||
|
|
||||||
rows = list((await session.execute(stmt)).scalars().all())
|
rows = list((await session.execute(stmt)).scalars().all())
|
||||||
return [r for r in rows if r]
|
return [r for r in rows if r]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> dict[str, int]:
|
||||||
|
"""Return per-type count of knowledge objects for the sidebar display."""
|
||||||
|
async with async_session() as session:
|
||||||
|
stmt = (
|
||||||
|
select(Note.note_type, func.count(Note.id))
|
||||||
|
.where(Note.user_id == user_id)
|
||||||
|
.where(Note.status.is_(None))
|
||||||
|
.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
||||||
|
.group_by(Note.note_type)
|
||||||
|
)
|
||||||
|
if tags:
|
||||||
|
for tag in tags:
|
||||||
|
stmt = stmt.where(Note.tags.contains([tag]))
|
||||||
|
rows = list((await session.execute(stmt)).all())
|
||||||
|
counts = {row[0]: row[1] for row in rows}
|
||||||
|
# Ensure all types present even if zero
|
||||||
|
for t in ("note", "person", "place", "list"):
|
||||||
|
counts.setdefault(t, 0)
|
||||||
|
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list"))
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
async def query_knowledge_ids(
|
async def query_knowledge_ids(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
note_type: str | None,
|
note_type: str | None,
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ async def list_notes(
|
|||||||
|
|
||||||
if parent_id is not None:
|
if parent_id is not None:
|
||||||
query = query.where(Note.parent_id == parent_id)
|
query = query.where(Note.parent_id == parent_id)
|
||||||
|
count_query = count_query.where(Note.parent_id == parent_id)
|
||||||
|
|
||||||
if no_project:
|
if no_project:
|
||||||
query = query.where(Note.project_id.is_(None))
|
query = query.where(Note.project_id.is_(None))
|
||||||
@@ -242,9 +243,15 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
|
|||||||
if not hasattr(note, key):
|
if not hasattr(note, key):
|
||||||
continue
|
continue
|
||||||
if key == "status" and isinstance(value, str):
|
if key == "status" and isinstance(value, str):
|
||||||
value = TaskStatus(value).value
|
try:
|
||||||
|
value = TaskStatus(value).value
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid status: {value!r}. Must be one of: {[s.value for s in TaskStatus]}")
|
||||||
elif key == "priority" and isinstance(value, str):
|
elif key == "priority" and isinstance(value, str):
|
||||||
value = TaskPriority(value).value
|
try:
|
||||||
|
value = TaskPriority(value).value
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid priority: {value!r}. Must be one of: {[p.value for p in TaskPriority]}")
|
||||||
elif key == "tags" and isinstance(value, list):
|
elif key == "tags" and isinstance(value, list):
|
||||||
value = _normalize_tags(value)
|
value = _normalize_tags(value)
|
||||||
setattr(note, key, value)
|
setattr(note, key, value)
|
||||||
|
|||||||
Reference in New Issue
Block a user