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:
2026-04-04 12:53:09 -04:00
parent 738245af5c
commit ed715dcc23
8 changed files with 328 additions and 58 deletions
+3 -1
View File
@@ -80,6 +80,8 @@ export const useNotesStore = defineStore("notes", () => {
tags?: string[];
project_id?: number | null;
milestone_id?: number | null;
note_type?: string;
metadata?: Record<string, string> | null;
}): Promise<Note> {
try {
return await apiPost<Note>("/api/notes", data);
@@ -91,7 +93,7 @@ export const useNotesStore = defineStore("notes", () => {
async function updateNote(
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> {
try {
const note = await apiPut<Note>(`/api/notes/${id}`, data);
+3
View File
@@ -1,5 +1,6 @@
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
export type TaskPriority = "none" | "low" | "medium" | "high";
export type NoteType = "note" | "person" | "place" | "list";
export interface Note {
id: number;
@@ -18,6 +19,8 @@ export interface Note {
recurrence_rule: Record<string, unknown> | null;
recurrence_next_spawn_at: string | null;
is_task: boolean;
note_type: NoteType;
metadata: Record<string, string>;
created_at: string;
updated_at: string;
}
+132 -7
View File
@@ -50,6 +50,28 @@ const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
const searchQuery = ref("");
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 ──────────────────────────────────────────────────────
const ID_BATCH = 100; // IDs fetched per server round-trip
@@ -156,7 +178,8 @@ function onSearchInput() {
searchDebounce = setTimeout(() => resetAndReobserve(), 380);
}
watch([activeType, activeTag, sortMode], () => resetAndReobserve());
watch([activeType, sortMode], () => resetAndReobserve());
watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
// ─── Today bar ────────────────────────────────────────────────────────────────
@@ -323,6 +346,7 @@ function setupObserver() {
onMounted(async () => {
await reset();
fetchTags();
fetchCounts();
fetchTodayBar();
await nextTick();
setupObserver();
@@ -366,15 +390,38 @@ onUnmounted(() => {
<!-- 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-label">Type</div>
<button
v-for="t in ([['', 'All'], ['note', 'Notes'], ['person', 'People'], ['place', 'Places'], ['list', 'Lists']] as [string, string][])"
:key="t[0]"
class="filter-btn"
:class="{ active: activeType === t[0] }"
@click="activeType = (t[0] as '' | 'note' | 'person' | 'place' | 'list')"
>{{ t[1] }}</button>
:class="{ active: activeType === '' }"
@click="activeType = ''"
>
<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 v-if="allTags.length > 0" class="filter-section">
@@ -664,9 +711,71 @@ onUnmounted(() => {
margin-bottom: 6px;
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;
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;
padding: 5px 8px;
border-radius: 7px;
@@ -685,6 +794,22 @@ onUnmounted(() => {
color: var(--color-primary, #818cf8);
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; }
/* ── Content area ────────────────────────────────────────── */
+98 -13
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
import { ref, reactive, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { NoteType } from "@/types/note";
import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
@@ -31,6 +32,8 @@ const body = ref("");
const tags = ref<string[]>([]);
const projectId = ref<number | null>(null);
const milestoneId = ref<number | null>(null);
const noteType = ref<NoteType>("note");
const entityMeta = reactive<Record<string, string>>({});
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
@@ -186,6 +189,8 @@ let savedBody = "";
let savedTags: string[] = [];
let savedProjectId: number | null = null;
let savedMilestoneId: number | null = null;
let savedNoteType: NoteType = "note";
let savedEntityMeta: Record<string, string> = {};
function markDirty() {
dirty.value =
@@ -193,7 +198,9 @@ function markDirty() {
body.value !== savedBody ||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
projectId.value !== savedProjectId ||
milestoneId.value !== savedMilestoneId;
milestoneId.value !== savedMilestoneId ||
noteType.value !== savedNoteType ||
JSON.stringify(entityMeta) !== JSON.stringify(savedEntityMeta);
}
function onBodyUpdate(newVal: string) {
@@ -210,24 +217,34 @@ onMounted(async () => {
tags.value = [...(store.currentNote.tags || [])];
projectId.value = store.currentNote.project_id ?? null;
milestoneId.value = store.currentNote.milestone_id ?? null;
noteType.value = (store.currentNote.note_type as NoteType) || "note";
Object.assign(entityMeta, store.currentNote.metadata || {});
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedNoteType = noteType.value;
savedEntityMeta = { ...entityMeta };
}
// 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
} else {
// New note: read type from query param
const qt = route.query.type as string | undefined;
if (qt && ["note", "person", "place", "list"].includes(qt)) {
noteType.value = qt as NoteType;
}
// 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() {
@@ -241,12 +258,16 @@ async function save() {
tags: tags.value,
project_id: projectId.value,
milestone_id: milestoneId.value,
note_type: noteType.value,
metadata: { ...entityMeta },
});
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedNoteType = noteType.value;
savedEntityMeta = { ...entityMeta };
dirty.value = false;
toast.show("Note saved");
} else {
@@ -256,6 +277,8 @@ async function save() {
tags: tags.value,
project_id: projectId.value,
milestone_id: milestoneId.value,
note_type: noteType.value,
metadata: { ...entityMeta },
});
dirty.value = false;
toast.show("Note created");
@@ -295,12 +318,15 @@ async function doAutoSave() {
await store.updateNote(noteId.value!, {
title: title.value, body: body.value, tags: tags.value,
project_id: projectId.value, milestone_id: milestoneId.value,
} as Record<string, unknown>);
note_type: noteType.value, metadata: { ...entityMeta },
});
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedNoteType = noteType.value;
savedEntityMeta = { ...entityMeta };
dirty.value = false;
toast.show("Auto-saved");
} catch {
@@ -439,6 +465,49 @@ onUnmounted(() => assist.clearSelection());
</template>
</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. MonFri 95" @input="markDirty" />
</div>
</template>
<!-- Link Suggestions -->
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
<div class="sb-label-row">
@@ -649,6 +718,22 @@ onUnmounted(() => assist.clearSelection());
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 {
display: flex;
+13
View File
@@ -129,3 +129,16 @@ async def list_knowledge_tags():
from fabledassistant.services.knowledge import get_knowledge_tags
tags = await get_knowledge_tags(uid, note_type=note_type)
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)
+44 -20
View File
@@ -104,18 +104,26 @@ async def create_note_route():
if proj:
project_id = proj.id
note = await create_note(
uid,
title=data.get("title", ""),
body=body,
tags=tags,
parent_id=data.get("parent_id"),
project_id=project_id,
milestone_id=data.get("milestone_id"),
status=status,
priority=priority,
due_date=due_date,
)
note_type = data.get("note_type", "note")
entity_meta = data.get("metadata") or None
try:
note = await create_note(
uid,
title=data.get("title", ""),
body=body,
tags=tags,
parent_id=data.get("parent_id"),
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 "")
if 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()
data = await request.get_json()
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:
fields[key] = data[key]
if "metadata" in data:
fields["entity_meta"] = data["metadata"] or None
if "due_date" in data:
if data["due_date"]:
@@ -222,7 +232,10 @@ async def update_note_route(note_id: int):
if "tags" in data:
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:
return not_found("Note")
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()
data = await request.get_json()
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:
fields[key] = data[key]
if "metadata" in data:
fields["entity_meta"] = data["metadata"] or None
if "due_date" in data:
result = parse_iso_date(data.get("due_date"), "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
if data["due_date"]:
result = parse_iso_date(data["due_date"], "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
else:
fields["due_date"] = None
if "tags" in data:
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:
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())
+26 -15
View File
@@ -141,32 +141,43 @@ async def _semantic_knowledge_search(
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."""
from sqlalchemy.dialects.postgresql import array
async with async_session() as session:
stmt = (
base = (
select(func.unnest(Note.tags).label("tag"))
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
)
if note_type:
stmt = stmt.where(Note.note_type == note_type)
base = base.where(Note.note_type == note_type)
else:
stmt = stmt.where(Note.note_type.in_(["note", "person", "place", "list"]))
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)
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
stmt = base.distinct().order_by("tag")
rows = list((await session.execute(stmt)).scalars().all())
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(
user_id: int,
note_type: str | None,
+9 -2
View File
@@ -188,6 +188,7 @@ async def list_notes(
if parent_id is not None:
query = query.where(Note.parent_id == parent_id)
count_query = count_query.where(Note.parent_id == parent_id)
if no_project:
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):
continue
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):
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):
value = _normalize_tags(value)
setattr(note, key, value)