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
+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;