Phase 20: Dedicated tag field — chip input, explicit tags array
Tags are now a first-class field rather than being auto-extracted from the note body. A new TagInput.vue chip component handles tag entry in both editor views with autocomplete, Enter/comma/backspace UX, and space-to-hyphen sanitization. Backend: - routes/notes.py: create reads tags from JSON; update accepts explicit tags (omit = keep existing); append_tag writes to tags array with dedup; suggest-tags accepts current_tags filter; remove extract_tags - routes/tasks.py: same — explicit tags on create/update; remove extract_tags - services/tag_suggestions.py: current_tags param replaces body extraction - services/tools.py: create_note tool schema adds tags param; executor passes it - services/llm.py: system prompt tells LLM to use tags param, not embed #tag in body Frontend: - components/TagInput.vue: new chip-based tag input (autocomplete, keyboard UX) - NoteEditorView.vue / TaskEditorView.vue: tags ref loaded from note.tags; TagInput placed between title and body; save/autosave include tags; suggest now adds chips; fetchTagSuggestions passes current_tags; dirty tracks tags - TiptapEditor.vue: remove fetchTags prop and TagSuggestion extension; keep TagDecoration for legacy inline #tag highlighting No DB migration needed — tags column already correct. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, nextTick } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string[];
|
||||||
|
fetchTags: (q: string) => Promise<string[]>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: string[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const inputValue = ref("");
|
||||||
|
const suggestions = ref<string[]>([]);
|
||||||
|
const showSuggestions = ref(false);
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
let fetchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function sanitize(raw: string): string {
|
||||||
|
return raw.trim().replace(/\s+/g, "-").replace(/^#+/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTag(raw: string) {
|
||||||
|
const tag = sanitize(raw);
|
||||||
|
if (!tag) return;
|
||||||
|
if (!props.modelValue.includes(tag)) {
|
||||||
|
emit("update:modelValue", [...props.modelValue, tag]);
|
||||||
|
}
|
||||||
|
inputValue.value = "";
|
||||||
|
suggestions.value = [];
|
||||||
|
showSuggestions.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(tag: string) {
|
||||||
|
emit("update:modelValue", props.modelValue.filter((t) => t !== tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter" || e.key === ",") {
|
||||||
|
e.preventDefault();
|
||||||
|
const val = inputValue.value.replace(/,$/, "");
|
||||||
|
if (val.trim()) {
|
||||||
|
addTag(val);
|
||||||
|
}
|
||||||
|
} else if (e.key === "Backspace" && !inputValue.value) {
|
||||||
|
const tags = props.modelValue;
|
||||||
|
if (tags.length > 0) {
|
||||||
|
emit("update:modelValue", tags.slice(0, -1));
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
showSuggestions.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput() {
|
||||||
|
const q = inputValue.value.trim().replace(/^#+/, "");
|
||||||
|
if (fetchDebounce !== null) clearTimeout(fetchDebounce);
|
||||||
|
if (!q) {
|
||||||
|
suggestions.value = [];
|
||||||
|
showSuggestions.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchDebounce = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const results = await props.fetchTags(q);
|
||||||
|
suggestions.value = results.filter((t) => !props.modelValue.includes(t));
|
||||||
|
showSuggestions.value = suggestions.value.length > 0;
|
||||||
|
} catch {
|
||||||
|
suggestions.value = [];
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur() {
|
||||||
|
// Small delay to allow click on suggestion
|
||||||
|
setTimeout(() => {
|
||||||
|
showSuggestions.value = false;
|
||||||
|
// Commit any pending text on blur
|
||||||
|
if (inputValue.value.trim()) {
|
||||||
|
addTag(inputValue.value);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusInput() {
|
||||||
|
nextTick(() => inputRef.value?.focus());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tag-input-wrapper" @click="focusInput">
|
||||||
|
<span
|
||||||
|
v-for="tag in modelValue"
|
||||||
|
:key="tag"
|
||||||
|
class="tag-chip"
|
||||||
|
>
|
||||||
|
#{{ tag }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tag-chip-remove"
|
||||||
|
@click.stop="removeTag(tag)"
|
||||||
|
tabindex="-1"
|
||||||
|
>×</button>
|
||||||
|
</span>
|
||||||
|
<div class="tag-input-inner">
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="inputValue"
|
||||||
|
type="text"
|
||||||
|
class="tag-input-field"
|
||||||
|
placeholder="Add tag..."
|
||||||
|
@keydown="onKeydown"
|
||||||
|
@input="onInput"
|
||||||
|
@blur="onBlur"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<ul v-if="showSuggestions" class="tag-autocomplete">
|
||||||
|
<li
|
||||||
|
v-for="s in suggestions"
|
||||||
|
:key="s"
|
||||||
|
class="tag-autocomplete-item"
|
||||||
|
@mousedown.prevent="addTag(s)"
|
||||||
|
>
|
||||||
|
#{{ s }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tag-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border: 1px solid var(--color-input-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
cursor: text;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
}
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tag-chip-remove {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.tag-chip-remove:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.tag-input-inner {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
.tag-input-field {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.tag-autocomplete {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: 280px;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.tag-autocomplete-item {
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.tag-autocomplete-item:hover {
|
||||||
|
background: var(--color-bg-hover, color-mix(in srgb, var(--color-primary) 8%, transparent));
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,18 +9,15 @@ import DOMPurify from "dompurify";
|
|||||||
import { serializeToMarkdown } from "@/utils/markdownSerializer";
|
import { serializeToMarkdown } from "@/utils/markdownSerializer";
|
||||||
import { TagDecoration } from "@/extensions/TagDecoration";
|
import { TagDecoration } from "@/extensions/TagDecoration";
|
||||||
import { WikilinkDecoration } from "@/extensions/WikilinkDecoration";
|
import { WikilinkDecoration } from "@/extensions/WikilinkDecoration";
|
||||||
import { TagSuggestion } from "@/extensions/TagSuggestion";
|
|
||||||
import { WikilinkSuggestion } from "@/extensions/WikilinkSuggestion";
|
import { WikilinkSuggestion } from "@/extensions/WikilinkSuggestion";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
fetchTags?: (query: string) => Promise<string[]>;
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
placeholder: "",
|
placeholder: "",
|
||||||
fetchTags: undefined,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -72,9 +69,6 @@ try {
|
|||||||
}),
|
}),
|
||||||
TagDecoration,
|
TagDecoration,
|
||||||
WikilinkDecoration,
|
WikilinkDecoration,
|
||||||
TagSuggestion.configure({
|
|
||||||
fetchTags: props.fetchTags ?? (async () => []),
|
|
||||||
}),
|
|
||||||
WikilinkSuggestion,
|
WikilinkSuggestion,
|
||||||
],
|
],
|
||||||
onUpdate({ editor: ed }) {
|
onUpdate({ editor: ed }) {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
async function createNote(data: {
|
async function createNote(data: {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
tags?: string[];
|
||||||
}): Promise<Note> {
|
}): Promise<Note> {
|
||||||
try {
|
try {
|
||||||
return await apiPost<Note>("/api/notes", data);
|
return await apiPost<Note>("/api/notes", data);
|
||||||
@@ -88,7 +89,7 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
|
|
||||||
async function updateNote(
|
async function updateNote(
|
||||||
id: number,
|
id: number,
|
||||||
data: Partial<Pick<Note, "title" | "body">>
|
data: Partial<Pick<Note, "title" | "body" | "tags">>
|
||||||
): Promise<Note> {
|
): Promise<Note> {
|
||||||
try {
|
try {
|
||||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
|||||||
async function createTask(data: {
|
async function createTask(data: {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
tags?: string[];
|
||||||
status?: TaskStatus;
|
status?: TaskStatus;
|
||||||
priority?: TaskPriority;
|
priority?: TaskPriority;
|
||||||
due_date?: string | null;
|
due_date?: string | null;
|
||||||
@@ -80,7 +81,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
|||||||
async function updateTask(
|
async function updateTask(
|
||||||
id: number,
|
id: number,
|
||||||
data: Partial<
|
data: Partial<
|
||||||
Pick<Task, "title" | "body" | "status" | "priority" | "due_date">
|
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date">
|
||||||
>
|
>
|
||||||
): Promise<Task> {
|
): Promise<Task> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { apiPost } from "@/api/client";
|
|||||||
import type { Editor } from "@tiptap/vue-3";
|
import type { Editor } from "@tiptap/vue-3";
|
||||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||||
|
import TagInput from "@/components/TagInput.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -17,6 +18,7 @@ const toast = useToastStore();
|
|||||||
|
|
||||||
const title = ref("");
|
const title = ref("");
|
||||||
const body = ref("");
|
const body = ref("");
|
||||||
|
const tags = ref<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);
|
||||||
@@ -106,6 +108,7 @@ async function fetchTagSuggestions() {
|
|||||||
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
|
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
|
||||||
title: title.value,
|
title: title.value,
|
||||||
body: body.value,
|
body: body.value,
|
||||||
|
current_tags: tags.value,
|
||||||
});
|
});
|
||||||
suggestedTags.value = res.suggested_tags;
|
suggestedTags.value = res.suggested_tags;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -117,7 +120,9 @@ async function fetchTagSuggestions() {
|
|||||||
|
|
||||||
function applyTagSuggestion(tag: string) {
|
function applyTagSuggestion(tag: string) {
|
||||||
if (appliedTags.value.has(tag)) return;
|
if (appliedTags.value.has(tag)) return;
|
||||||
body.value = body.value.trimEnd() + `\n#${tag}`;
|
if (!tags.value.includes(tag)) {
|
||||||
|
tags.value = [...tags.value, tag];
|
||||||
|
}
|
||||||
appliedTags.value.add(tag);
|
appliedTags.value.add(tag);
|
||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
@@ -130,9 +135,13 @@ function dismissTagSuggestions() {
|
|||||||
// Track saved state for dirty detection
|
// Track saved state for dirty detection
|
||||||
let savedTitle = "";
|
let savedTitle = "";
|
||||||
let savedBody = "";
|
let savedBody = "";
|
||||||
|
let savedTags: string[] = [];
|
||||||
|
|
||||||
function markDirty() {
|
function markDirty() {
|
||||||
dirty.value = title.value !== savedTitle || body.value !== savedBody;
|
dirty.value =
|
||||||
|
title.value !== savedTitle ||
|
||||||
|
body.value !== savedBody ||
|
||||||
|
JSON.stringify(tags.value) !== JSON.stringify(savedTags);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBodyUpdate(newVal: string) {
|
function onBodyUpdate(newVal: string) {
|
||||||
@@ -146,8 +155,10 @@ onMounted(async () => {
|
|||||||
if (store.currentNote) {
|
if (store.currentNote) {
|
||||||
title.value = store.currentNote.title;
|
title.value = store.currentNote.title;
|
||||||
body.value = store.currentNote.body;
|
body.value = store.currentNote.body;
|
||||||
|
tags.value = [...(store.currentNote.tags || [])];
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
|
savedTags = [...tags.value];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -160,15 +171,18 @@ async function save() {
|
|||||||
await store.updateNote(noteId.value!, {
|
await store.updateNote(noteId.value!, {
|
||||||
title: title.value,
|
title: title.value,
|
||||||
body: body.value,
|
body: body.value,
|
||||||
|
tags: tags.value,
|
||||||
});
|
});
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
|
savedTags = [...tags.value];
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Note saved");
|
toast.show("Note saved");
|
||||||
} else {
|
} else {
|
||||||
const note = await store.createNote({
|
const note = await store.createNote({
|
||||||
title: title.value,
|
title: title.value,
|
||||||
body: body.value,
|
body: body.value,
|
||||||
|
tags: tags.value,
|
||||||
});
|
});
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Note created");
|
toast.show("Note created");
|
||||||
@@ -209,9 +223,10 @@ async function autoSave() {
|
|||||||
if (!isEditing.value || !dirty.value || saving.value) return;
|
if (!isEditing.value || !dirty.value || saving.value) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
await store.updateNote(noteId.value!, { title: title.value, body: body.value });
|
await store.updateNote(noteId.value!, { title: title.value, body: body.value, tags: tags.value });
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
|
savedTags = [...tags.value];
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Auto-saved");
|
toast.show("Auto-saved");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -275,6 +290,12 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
@input="markDirty"
|
@input="markDirty"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TagInput
|
||||||
|
v-model="tags"
|
||||||
|
:fetchTags="(q: string) => store.fetchAllTags(q)"
|
||||||
|
@update:modelValue="markDirty"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="tag-suggest-row">
|
<div class="tag-suggest-row">
|
||||||
<button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags">
|
<button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags">
|
||||||
{{ suggestingTags ? "Suggesting..." : "Suggest tags" }}
|
{{ suggestingTags ? "Suggesting..." : "Suggest tags" }}
|
||||||
@@ -318,8 +339,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
<TiptapEditor
|
<TiptapEditor
|
||||||
ref="editorRef"
|
ref="editorRef"
|
||||||
:modelValue="body"
|
:modelValue="body"
|
||||||
placeholder="Write your note in Markdown... Use #tags inline"
|
placeholder="Write your note in Markdown..."
|
||||||
:fetchTags="(q: string) => store.fetchAllTags(q)"
|
|
||||||
@update:modelValue="onBodyUpdate"
|
@update:modelValue="onBodyUpdate"
|
||||||
@selectionChange="onSelectionChange"
|
@selectionChange="onSelectionChange"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { TaskStatus, TaskPriority } from "@/types/task";
|
|||||||
import type { Editor } from "@tiptap/vue-3";
|
import type { Editor } from "@tiptap/vue-3";
|
||||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||||
|
import TagInput from "@/components/TagInput.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -20,6 +21,7 @@ const toast = useToastStore();
|
|||||||
|
|
||||||
const title = ref("");
|
const title = ref("");
|
||||||
const body = ref("");
|
const body = ref("");
|
||||||
|
const tags = ref<string[]>([]);
|
||||||
const status = ref<TaskStatus>("todo");
|
const status = ref<TaskStatus>("todo");
|
||||||
const priority = ref<TaskPriority>("none");
|
const priority = ref<TaskPriority>("none");
|
||||||
const dueDate = ref("");
|
const dueDate = ref("");
|
||||||
@@ -112,6 +114,7 @@ async function fetchTagSuggestions() {
|
|||||||
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
|
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
|
||||||
title: title.value,
|
title: title.value,
|
||||||
body: body.value,
|
body: body.value,
|
||||||
|
current_tags: tags.value,
|
||||||
});
|
});
|
||||||
suggestedTags.value = res.suggested_tags;
|
suggestedTags.value = res.suggested_tags;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -123,7 +126,9 @@ async function fetchTagSuggestions() {
|
|||||||
|
|
||||||
function applyTagSuggestion(tag: string) {
|
function applyTagSuggestion(tag: string) {
|
||||||
if (appliedTags.value.has(tag)) return;
|
if (appliedTags.value.has(tag)) return;
|
||||||
body.value = body.value.trimEnd() + `\n#${tag}`;
|
if (!tags.value.includes(tag)) {
|
||||||
|
tags.value = [...tags.value, tag];
|
||||||
|
}
|
||||||
appliedTags.value.add(tag);
|
appliedTags.value.add(tag);
|
||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
@@ -135,6 +140,7 @@ function dismissTagSuggestions() {
|
|||||||
|
|
||||||
let savedTitle = "";
|
let savedTitle = "";
|
||||||
let savedBody = "";
|
let savedBody = "";
|
||||||
|
let savedTags: string[] = [];
|
||||||
let savedStatus: TaskStatus = "todo";
|
let savedStatus: TaskStatus = "todo";
|
||||||
let savedPriority: TaskPriority = "none";
|
let savedPriority: TaskPriority = "none";
|
||||||
let savedDueDate = "";
|
let savedDueDate = "";
|
||||||
@@ -143,6 +149,7 @@ function markDirty() {
|
|||||||
dirty.value =
|
dirty.value =
|
||||||
title.value !== savedTitle ||
|
title.value !== savedTitle ||
|
||||||
body.value !== savedBody ||
|
body.value !== savedBody ||
|
||||||
|
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||||
status.value !== savedStatus ||
|
status.value !== savedStatus ||
|
||||||
priority.value !== savedPriority ||
|
priority.value !== savedPriority ||
|
||||||
dueDate.value !== savedDueDate;
|
dueDate.value !== savedDueDate;
|
||||||
@@ -159,11 +166,13 @@ onMounted(async () => {
|
|||||||
if (store.currentTask) {
|
if (store.currentTask) {
|
||||||
title.value = store.currentTask.title;
|
title.value = store.currentTask.title;
|
||||||
body.value = store.currentTask.body;
|
body.value = store.currentTask.body;
|
||||||
|
tags.value = [...(store.currentTask.tags || [])];
|
||||||
status.value = store.currentTask.status as TaskStatus;
|
status.value = store.currentTask.status as TaskStatus;
|
||||||
priority.value = store.currentTask.priority as TaskPriority;
|
priority.value = store.currentTask.priority as TaskPriority;
|
||||||
dueDate.value = store.currentTask.due_date || "";
|
dueDate.value = store.currentTask.due_date || "";
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
|
savedTags = [...tags.value];
|
||||||
savedStatus = status.value;
|
savedStatus = status.value;
|
||||||
savedPriority = priority.value;
|
savedPriority = priority.value;
|
||||||
savedDueDate = dueDate.value;
|
savedDueDate = dueDate.value;
|
||||||
@@ -178,6 +187,7 @@ async function save() {
|
|||||||
const data = {
|
const data = {
|
||||||
title: title.value,
|
title: title.value,
|
||||||
body: body.value,
|
body: body.value,
|
||||||
|
tags: tags.value,
|
||||||
status: status.value,
|
status: status.value,
|
||||||
priority: priority.value,
|
priority: priority.value,
|
||||||
due_date: dueDate.value || null,
|
due_date: dueDate.value || null,
|
||||||
@@ -186,6 +196,7 @@ async function save() {
|
|||||||
await store.updateTask(taskId.value!, data);
|
await store.updateTask(taskId.value!, data);
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
|
savedTags = [...tags.value];
|
||||||
savedStatus = status.value;
|
savedStatus = status.value;
|
||||||
savedPriority = priority.value;
|
savedPriority = priority.value;
|
||||||
savedDueDate = dueDate.value;
|
savedDueDate = dueDate.value;
|
||||||
@@ -235,12 +246,14 @@ async function autoSave() {
|
|||||||
await store.updateTask(taskId.value!, {
|
await store.updateTask(taskId.value!, {
|
||||||
title: title.value,
|
title: title.value,
|
||||||
body: body.value,
|
body: body.value,
|
||||||
|
tags: tags.value,
|
||||||
status: status.value,
|
status: status.value,
|
||||||
priority: priority.value,
|
priority: priority.value,
|
||||||
due_date: dueDate.value || null,
|
due_date: dueDate.value || null,
|
||||||
});
|
});
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
|
savedTags = [...tags.value];
|
||||||
savedStatus = status.value;
|
savedStatus = status.value;
|
||||||
savedPriority = priority.value;
|
savedPriority = priority.value;
|
||||||
savedDueDate = dueDate.value;
|
savedDueDate = dueDate.value;
|
||||||
@@ -333,6 +346,12 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TagInput
|
||||||
|
v-model="tags"
|
||||||
|
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
|
||||||
|
@update:modelValue="markDirty"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="tag-suggest-row">
|
<div class="tag-suggest-row">
|
||||||
<button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags">
|
<button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags">
|
||||||
{{ suggestingTags ? "Suggesting..." : "Suggest tags" }}
|
{{ suggestingTags ? "Suggesting..." : "Suggest tags" }}
|
||||||
@@ -376,8 +395,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
<TiptapEditor
|
<TiptapEditor
|
||||||
ref="editorRef"
|
ref="editorRef"
|
||||||
:modelValue="body"
|
:modelValue="body"
|
||||||
placeholder="Describe this task... Use #tags inline"
|
placeholder="Describe this task..."
|
||||||
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
|
|
||||||
@update:modelValue="onBodyUpdate"
|
@update:modelValue="onBodyUpdate"
|
||||||
@selectionChange="onSelectionChange"
|
@selectionChange="onSelectionChange"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ from fabledassistant.services.notes import (
|
|||||||
)
|
)
|
||||||
from fabledassistant.services.settings import get_setting
|
from fabledassistant.services.settings import get_setting
|
||||||
from fabledassistant.services.tag_suggestions import suggest_tags
|
from fabledassistant.services.tag_suggestions import suggest_tags
|
||||||
from fabledassistant.utils.tags import extract_tags
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -67,7 +66,7 @@ async def create_note_route():
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
data = await request.get_json()
|
data = await request.get_json()
|
||||||
body = data.get("body", "")
|
body = data.get("body", "")
|
||||||
tags = extract_tags(body)
|
tags = data.get("tags", [])
|
||||||
|
|
||||||
# Optional task fields
|
# Optional task fields
|
||||||
status = data.get("status")
|
status = data.get("status")
|
||||||
@@ -111,7 +110,8 @@ async def suggest_tags_route():
|
|||||||
data = await request.get_json()
|
data = await request.get_json()
|
||||||
title = data.get("title", "")
|
title = data.get("title", "")
|
||||||
body = data.get("body", "")
|
body = data.get("body", "")
|
||||||
tags = await suggest_tags(uid, title, body)
|
current_tags = data.get("current_tags", [])
|
||||||
|
tags = await suggest_tags(uid, title, body, current_tags=current_tags)
|
||||||
return jsonify({"suggested_tags": tags})
|
return jsonify({"suggested_tags": tags})
|
||||||
|
|
||||||
|
|
||||||
@@ -128,10 +128,10 @@ async def append_tag_route(note_id: int):
|
|||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "Note not found"}), 404
|
return jsonify({"error": "Note not found"}), 404
|
||||||
|
|
||||||
# Append #tag to body
|
existing = list(note.tags or [])
|
||||||
new_body = note.body.rstrip() + f"\n#{tag}" if note.body else f"#{tag}"
|
if tag not in existing:
|
||||||
tags = extract_tags(new_body)
|
existing.append(tag)
|
||||||
updated = await update_note(uid, note_id, body=new_body, tags=tags)
|
updated = await update_note(uid, note_id, tags=existing)
|
||||||
return jsonify(updated.to_dict())
|
return jsonify(updated.to_dict())
|
||||||
|
|
||||||
|
|
||||||
@@ -189,8 +189,8 @@ async def update_note_route(note_id: int):
|
|||||||
else:
|
else:
|
||||||
fields["due_date"] = None
|
fields["due_date"] = None
|
||||||
|
|
||||||
if "body" in fields:
|
if "tags" in data:
|
||||||
fields["tags"] = extract_tags(fields["body"])
|
fields["tags"] = data["tags"]
|
||||||
note = await update_note(uid, note_id, **fields)
|
note = await update_note(uid, note_id, **fields)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "Note not found"}), 404
|
return jsonify({"error": "Note not found"}), 404
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from fabledassistant.services.notes import (
|
|||||||
list_notes,
|
list_notes,
|
||||||
update_note,
|
update_note,
|
||||||
)
|
)
|
||||||
from fabledassistant.utils.tags import extract_tags
|
|
||||||
|
|
||||||
tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
|
tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
|
||||||
|
|
||||||
@@ -60,7 +59,7 @@ async def create_task_route():
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
data = await request.get_json()
|
data = await request.get_json()
|
||||||
body = data.get("body", "") or data.get("description", "")
|
body = data.get("body", "") or data.get("description", "")
|
||||||
tags = extract_tags(body)
|
tags = data.get("tags", [])
|
||||||
|
|
||||||
due_date = None
|
due_date = None
|
||||||
if data.get("due_date"):
|
if data.get("due_date"):
|
||||||
@@ -121,8 +120,8 @@ async def update_task_route(task_id: int):
|
|||||||
else:
|
else:
|
||||||
fields["due_date"] = None
|
fields["due_date"] = None
|
||||||
|
|
||||||
if "body" in fields:
|
if "tags" in data:
|
||||||
fields["tags"] = extract_tags(fields["body"])
|
fields["tags"] = data["tags"]
|
||||||
|
|
||||||
task = await update_note(uid, task_id, **fields)
|
task = await update_note(uid, task_id, **fields)
|
||||||
if task is None:
|
if task is None:
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ async def build_context(
|
|||||||
tool_lines.append("When the user says 'remind me' with a time before an event, use the reminder_minutes parameter.")
|
tool_lines.append("When the user says 'remind me' with a time before an event, use the reminder_minutes parameter.")
|
||||||
tool_lines.append("Use search_todos to find a specific CalDAV todo by keyword when list_todos would return too many results.")
|
tool_lines.append("Use search_todos to find a specific CalDAV todo by keyword when list_todos would return too many results.")
|
||||||
tool_lines.append("For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format.")
|
tool_lines.append("For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format.")
|
||||||
tool_lines.append("When writing #tags in note bodies, use hyphens for multi-word tags (e.g. #science-fiction, #space-travel). Never use spaces inside a tag.")
|
tool_lines.append("When creating notes, use the `tags` parameter — do not embed #tag text in the note body.")
|
||||||
tool_lines.append(
|
tool_lines.append(
|
||||||
"Use update_note to edit/expand an existing note OR to update a task's status/priority/due_date. "
|
"Use update_note to edit/expand an existing note OR to update a task's status/priority/due_date. "
|
||||||
"Use create_note ONLY for genuinely new notes with a different title. "
|
"Use create_note ONLY for genuinely new notes with a different title. "
|
||||||
|
|||||||
@@ -8,16 +8,15 @@ from fabledassistant.config import Config
|
|||||||
from fabledassistant.services.llm import generate_completion
|
from fabledassistant.services.llm import generate_completion
|
||||||
from fabledassistant.services.notes import get_all_tags
|
from fabledassistant.services.notes import get_all_tags
|
||||||
from fabledassistant.services.settings import get_setting
|
from fabledassistant.services.settings import get_setting
|
||||||
from fabledassistant.utils.tags import extract_tags
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def suggest_tags(user_id: int, title: str, body: str) -> list[str]:
|
async def suggest_tags(user_id: int, title: str, body: str, current_tags: list[str] | None = None) -> list[str]:
|
||||||
"""Suggest relevant tags for a note/task based on its content.
|
"""Suggest relevant tags for a note/task based on its content.
|
||||||
|
|
||||||
Returns a list of tag strings (without # prefix), excluding tags
|
Returns a list of tag strings (without # prefix), excluding tags
|
||||||
already present in the body.
|
already present in current_tags.
|
||||||
"""
|
"""
|
||||||
if not title.strip() and not body.strip():
|
if not title.strip() and not body.strip():
|
||||||
return []
|
return []
|
||||||
@@ -54,9 +53,9 @@ async def suggest_tags(user_id: int, title: str, body: str) -> list[str]:
|
|||||||
|
|
||||||
tags = _parse_tag_list(response)
|
tags = _parse_tag_list(response)
|
||||||
|
|
||||||
# Filter out tags already in the body
|
# Filter out tags already applied
|
||||||
body_tags = set(extract_tags(body))
|
existing = set(current_tags or [])
|
||||||
tags = [t for t in tags if t not in body_tags]
|
tags = [t for t in tags if t not in existing]
|
||||||
|
|
||||||
return tags[:5]
|
return tags[:5]
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ _CORE_TOOLS = [
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The note content in markdown",
|
"description": "The note content in markdown",
|
||||||
},
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Tags for the note (without # prefix, hyphens for multi-word: [\"science-fiction\", \"story/idea\"]). Do NOT embed #tags in the note body.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": ["title"],
|
"required": ["title"],
|
||||||
},
|
},
|
||||||
@@ -710,10 +715,12 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
elif tool_name == "create_note":
|
elif tool_name == "create_note":
|
||||||
note_title = arguments.get("title", "Untitled Note")
|
note_title = arguments.get("title", "Untitled Note")
|
||||||
note_body = arguments.get("body", "")
|
note_body = arguments.get("body", "")
|
||||||
|
note_tags = arguments.get("tags", [])
|
||||||
note = await create_note(
|
note = await create_note(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
title=note_title,
|
title=note_title,
|
||||||
body=note_body,
|
body=note_body,
|
||||||
|
tags=note_tags,
|
||||||
)
|
)
|
||||||
suggested = await suggest_tags(user_id, note_title, note_body)
|
suggested = await suggest_tags(user_id, note_title, note_body)
|
||||||
_schedule_embedding(note.id, user_id, note_title, note_body)
|
_schedule_embedding(note.id, user_id, note_title, note_body)
|
||||||
|
|||||||
+28
-19
@@ -12,7 +12,7 @@
|
|||||||
> Include file-level details in the commit body when the change is non-trivial.
|
> Include file-level details in the commit body when the change is non-trivial.
|
||||||
|
|
||||||
## Last Updated
|
## Last Updated
|
||||||
2026-02-26 — Phase 19: Light mode indigo theme, OLLAMA_NUM_CTX, model dropdowns, optimistic streaming
|
2026-02-26 — Phase 20: Dedicated tag field (chip input), tags no longer extracted from body
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
||||||
@@ -41,10 +41,11 @@ for AI-assisted features.
|
|||||||
intercepting API GETs.
|
intercepting API GETs.
|
||||||
- **LLM integration is a separate service:** The app communicates with Ollama (or
|
- **LLM integration is a separate service:** The app communicates with Ollama (or
|
||||||
compatible) over HTTP.
|
compatible) over HTTP.
|
||||||
- **Inline tag extraction:** Tags are extracted from note/task body text using
|
- **First-class tag field:** Tags live in the `tags ARRAY[text]` column and are
|
||||||
`#tag` syntax (Obsidian-style), not manually entered. Backend is source of truth
|
explicitly set by the client — they are NOT auto-extracted from the body text.
|
||||||
for tag extraction. The `TAG_RE` regex uses `(?<!&)` negative lookbehind to avoid
|
`extract_tags()` in `utils/tags.py` is preserved but no longer called on save.
|
||||||
matching HTML entities like `'` as tags.
|
`TagDecoration.ts` keeps visual `#tag` highlighting in existing notes that have
|
||||||
|
inline tags for cosmetic backward compatibility.
|
||||||
- **Hierarchical tags:** `#project/webapp` stored as `"project/webapp"`. Filtering
|
- **Hierarchical tags:** `#project/webapp` stored as `"project/webapp"`. Filtering
|
||||||
by `project` matches both `project` and `project/*` children via SQL `unnest` +
|
by `project` matches both `project` and `project/*` children via SQL `unnest` +
|
||||||
`LIKE` prefix.
|
`LIKE` prefix.
|
||||||
@@ -159,7 +160,7 @@ for AI-assisted features.
|
|||||||
`priority` (nullable str — `none`/`low`/`medium`/`high`), `due_date` (nullable date),
|
`priority` (nullable str — `none`/`low`/`medium`/`high`), `due_date` (nullable date),
|
||||||
`created_at`, `updated_at`
|
`created_at`, `updated_at`
|
||||||
- **A note is a task when `status IS NOT NULL`** — the `is_task` property checks this
|
- **A note is a task when `status IS NOT NULL`** — the `is_task` property checks this
|
||||||
- Tags are auto-extracted from body text on create/update via `#tag` regex
|
- Tags are explicitly provided by the client — NOT extracted from body text
|
||||||
- Supports hierarchical organization via `parent_id`
|
- Supports hierarchical organization via `parent_id`
|
||||||
- Lookup by exact title via `get_note_by_title()` for wikilink resolution
|
- Lookup by exact title via `get_note_by_title()` for wikilink resolution
|
||||||
- Auto-create via `get_or_create_note_by_title()` for wikilink clicks
|
- Auto-create via `get_or_create_note_by_title()` for wikilink clicks
|
||||||
@@ -345,10 +346,10 @@ fabledassistant/
|
|||||||
│ │ ├── HomeView.vue # Chat-first dashboard: quick actions + chat widget (top, full-width), inline response panel, two-column grid (3fr tasks / 2fr notes); task sections: Overdue, Due Today, Due This Week, High Priority, In Progress, Other (capped 10, due-dated first); 8 recent notes; model warming on mount
|
│ │ ├── HomeView.vue # Chat-first dashboard: quick actions + chat widget (top, full-width), inline response panel, two-column grid (3fr tasks / 2fr notes); task sections: Overdue, Due Today, Due This Week, High Priority, In Progress, Other (capped 10, due-dated first); 8 recent notes; model warming on mount
|
||||||
│ │ ├── SettingsView.vue # Settings page: assistant name, chat/intent model dropdowns (populated from installed models), email change (with password confirmation for local-auth users), change password, notifications, CalDAV, SMTP (admin), base URL (admin), data export/restore (admin)
|
│ │ ├── SettingsView.vue # Settings page: assistant name, chat/intent model dropdowns (populated from installed models), email change (with password confirmation for local-auth users), change password, notifications, CalDAV, SMTP (admin), base URL (admin), data export/restore (admin)
|
||||||
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
|
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
|
||||||
│ │ ├── NoteEditorView.vue # Create/edit: Tiptap editor, sticky toolbar, AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions, Ctrl+S, auto-save (5min), unsaved guard; styles from editor-shared.css
|
│ │ ├── NoteEditorView.vue # Create/edit: Tiptap editor, sticky toolbar, TagInput chip field (between title and body), AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions (adds chips), Ctrl+S, auto-save (5min), unsaved guard; styles from editor-shared.css
|
||||||
│ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task, backlinks, table of contents sidebar
|
│ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task, backlinks, table of contents sidebar
|
||||||
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination
|
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination
|
||||||
│ │ ├── TaskEditorView.vue # Create/edit task: Tiptap editor, sticky toolbar, AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions, Ctrl+S, auto-save (5min), dirty guard; styles from editor-shared.css + task-specific scoped styles
|
│ │ ├── TaskEditorView.vue # Create/edit task: Tiptap editor, sticky toolbar, TagInput chip field (between metadata fields and body), AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions (adds chips), Ctrl+S, auto-save (5min), dirty guard; styles from editor-shared.css + task-specific scoped styles
|
||||||
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges (isOverdue uses ISO string comparison), convert-to-note, backlinks, table of contents sidebar
|
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges (isOverdue uses ISO string comparison), convert-to-note, backlinks, table of contents sidebar
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── LogsView.vue # Admin log viewer: stats summary, category/search/date filters, paginated table with IP column + expandable detail rows (expands on ip_address or details)
|
│ │ ├── LogsView.vue # Admin log viewer: stats summary, category/search/date filters, paginated table with IP column + expandable detail rows (expands on ip_address or details)
|
||||||
@@ -363,7 +364,8 @@ fabledassistant/
|
|||||||
│ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling
|
│ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling
|
||||||
│ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none")
|
│ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none")
|
||||||
│ │ ├── MarkdownToolbar.vue # Tiptap command-based toolbar: bold/italic/link/list/heading with active state highlighting
|
│ │ ├── MarkdownToolbar.vue # Tiptap command-based toolbar: bold/italic/link/list/heading with active state highlighting
|
||||||
│ │ ├── TiptapEditor.vue # Tiptap wrapper: markdown↔HTML round-trip, paste handling, selection change emit (closest-match offset strategy), expose editor
|
│ │ ├── TagInput.vue # Chip-based tag input: Enter/comma/click to confirm, Backspace removes last, × removes chip, autocomplete from /api/notes/tags, space→hyphen sanitization
|
||||||
|
│ │ ├── TiptapEditor.vue # Tiptap wrapper: markdown↔HTML round-trip, paste handling, selection change emit (closest-match offset strategy), expose editor; no fetchTags prop (TagSuggestion removed, TagDecoration kept)
|
||||||
│ │ ├── SuggestionDropdown.vue # Shared autocomplete dropdown for tag/wikilink suggestions
|
│ │ ├── SuggestionDropdown.vue # Shared autocomplete dropdown for tag/wikilink suggestions
|
||||||
│ │ ├── SearchBar.vue # Debounced search input
|
│ │ ├── SearchBar.vue # Debounced search input
|
||||||
│ │ ├── TagPill.vue # Clickable/dismissible tag pill
|
│ │ ├── TagPill.vue # Clickable/dismissible tag pill
|
||||||
@@ -398,14 +400,14 @@ fabledassistant/
|
|||||||
| GET | `/api/admin/logs` | List log entries (admin only, params: `category`, `user_id`, `search`, `date_from`, `date_to`, `limit`, `offset`) |
|
| GET | `/api/admin/logs` | List log entries (admin only, params: `category`, `user_id`, `search`, `date_from`, `date_to`, `limit`, `offset`) |
|
||||||
| GET | `/api/admin/logs/stats` | Get log category counts (admin only) |
|
| GET | `/api/admin/logs/stats` | Get log category counts (admin only) |
|
||||||
| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`; defaults to `is_task=false` — plain notes only; `?is_task=true` for tasks, `?all=true` for everything) |
|
| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`; defaults to `is_task=false` — plain notes only; `?is_task=true` for tasks, `?all=true` for everything) |
|
||||||
| POST | `/api/notes` | Create note (body: `{title, body, status?, priority?, due_date?}` — tags auto-extracted) |
|
| POST | `/api/notes` | Create note (body: `{title, body, tags?: string[], status?, priority?, due_date?}` — tags explicit array) |
|
||||||
| GET | `/api/notes/tags` | List all tags from notes table (param: `q` for filter) |
|
| GET | `/api/notes/tags` | List all tags from notes table (param: `q` for filter) |
|
||||||
| POST | `/api/notes/suggest-tags` | LLM-suggest tags for content (body: `{title, body}`, returns `{suggested_tags: [...]}`) |
|
| POST | `/api/notes/suggest-tags` | LLM-suggest tags (body: `{title, body, current_tags?: string[]}`, returns `{suggested_tags: [...]}`) |
|
||||||
| POST | `/api/notes/:id/append-tag` | Append `#tag` to note body (body: `{tag}`, returns updated note) |
|
| POST | `/api/notes/:id/append-tag` | Add tag to note's tags array with deduplication (body: `{tag}`, returns updated note) |
|
||||||
| GET | `/api/notes/by-title?title=...` | Resolve note by exact title (case-insensitive) |
|
| GET | `/api/notes/by-title?title=...` | Resolve note by exact title (case-insensitive) |
|
||||||
| POST | `/api/notes/resolve-title` | Get-or-create note by title (for wikilink clicks) |
|
| POST | `/api/notes/resolve-title` | Get-or-create note by title (for wikilink clicks) |
|
||||||
| GET | `/api/notes/:id` | Get single note (response includes `is_task`, `status`, `priority`, `due_date`) |
|
| GET | `/api/notes/:id` | Get single note (response includes `is_task`, `status`, `priority`, `due_date`) |
|
||||||
| PUT | `/api/notes/:id` | Update note (body: `{title?, body?, status?, priority?, due_date?}` — tags re-extracted if body changes) |
|
| PUT | `/api/notes/:id` | Update note (body: `{title?, body?, tags?: string[], status?, priority?, due_date?}` — omitting tags keeps existing) |
|
||||||
| DELETE | `/api/notes/:id` | Delete note (simple delete, no cascade) |
|
| DELETE | `/api/notes/:id` | Delete note (simple delete, no cascade) |
|
||||||
| POST | `/api/notes/:id/convert-to-task` | Set `status='todo'`, `priority='none'` on note (returns 200) |
|
| POST | `/api/notes/:id/convert-to-task` | Set `status='todo'`, `priority='none'` on note (returns 200) |
|
||||||
| POST | `/api/notes/:id/convert-to-note` | Clear `status`, `priority`, `due_date` from note (returns 200) |
|
| POST | `/api/notes/:id/convert-to-note` | Clear `status`, `priority`, `due_date` from note (returns 200) |
|
||||||
@@ -420,9 +422,9 @@ fabledassistant/
|
|||||||
| GET | `/api/admin/invitations` | List pending invitations (admin only) |
|
| GET | `/api/admin/invitations` | List pending invitations (admin only) |
|
||||||
| DELETE | `/api/admin/invitations/:id` | Revoke invitation (admin only) |
|
| DELETE | `/api/admin/invitations/:id` | Revoke invitation (admin only) |
|
||||||
| GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `due_before`, `due_after`, `sort`, `order`, `limit`, `offset`) — queries notes where `status IS NOT NULL` |
|
| GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `due_before`, `due_after`, `sort`, `order`, `limit`, `offset`) — queries notes where `status IS NOT NULL` |
|
||||||
| POST | `/api/tasks` | Create task (body: `{title, body, status?, priority?, due_date?}` — accepts `description` as fallback for `body`) |
|
| POST | `/api/tasks` | Create task (body: `{title, body, tags?: string[], status?, priority?, due_date?}` — accepts `description` as fallback for `body`) |
|
||||||
| GET | `/api/tasks/:id` | Get single task |
|
| GET | `/api/tasks/:id` | Get single task |
|
||||||
| PUT | `/api/tasks/:id` | Update task (accepts `body` or `description`, prefers `body`) |
|
| PUT | `/api/tasks/:id` | Update task (body: `{title?, body?, tags?: string[], status?, priority?, due_date?}` — accepts `description` as fallback for `body`) |
|
||||||
| PATCH | `/api/tasks/:id/status` | Quick status toggle (body: `{status}`) |
|
| PATCH | `/api/tasks/:id/status` | Quick status toggle (body: `{status}`) |
|
||||||
| DELETE | `/api/tasks/:id` | Delete task (simple delete) |
|
| DELETE | `/api/tasks/:id` | Delete task (simple delete) |
|
||||||
| GET | `/api/chat/conversations` | List conversations (params: `limit`, `offset`) |
|
| GET | `/api/chat/conversations` | List conversations (params: `limit`, `offset`) |
|
||||||
@@ -531,7 +533,7 @@ When adding a new migration, follow these conventions:
|
|||||||
## Implemented Features
|
## Implemented Features
|
||||||
|
|
||||||
### Notes & Tasks
|
### Notes & Tasks
|
||||||
- Full CRUD with markdown bodies, Obsidian-style `#tag` extraction (skips code fences), hierarchical tag filtering
|
- Full CRUD with markdown bodies, first-class tag array (chip input in editors), hierarchical tag filtering
|
||||||
- Unified data model: a task is a note with `status IS NOT NULL` — convert freely between note ↔ task
|
- Unified data model: a task is a note with `status IS NOT NULL` — convert freely between note ↔ task
|
||||||
- Task attributes: status (todo/in_progress/done), priority (none/low/medium/high), due_date
|
- Task attributes: status (todo/in_progress/done), priority (none/low/medium/high), due_date
|
||||||
- Obsidian-style `[[wikilinks]]` with auto-create on click, backlinks ("what links here")
|
- Obsidian-style `[[wikilinks]]` with auto-create on click, backlinks ("what links here")
|
||||||
@@ -647,12 +649,19 @@ When adding a new migration, follow these conventions:
|
|||||||
VALARM components added for reminders. Attendees via mailto: vCalAddress.
|
VALARM components added for reminders. Attendees via mailto: vCalAddress.
|
||||||
Multi-calendar search: when no specific calendar configured, all calendars are scanned.
|
Multi-calendar search: when no specific calendar configured, all calendars are scanned.
|
||||||
Runs synchronous caldav library calls in asyncio executor. Settings UI for CalDAV config including timezone.
|
Runs synchronous caldav library calls in asyncio executor. Settings UI for CalDAV config including timezone.
|
||||||
|
- **Dedicated tag chip input (Phase 20):** Tags are now a first-class UI field. `TagInput.vue` is a
|
||||||
|
chip-based input placed between the title and body in both editor views. Chips confirmed with
|
||||||
|
Enter/comma/autocomplete click; Backspace removes last chip; × removes individual chips. Autocomplete
|
||||||
|
fetches from `/api/notes/tags?q=`. Tags saved as explicit `tags: string[]` in create/update payloads.
|
||||||
|
`TagSuggestion` Tiptap extension removed; `TagDecoration` kept for legacy inline `#tag` display.
|
||||||
- **LLM-suggested tags:** Backend service (`tag_suggestions.py`) prompts LLM with existing user tags
|
- **LLM-suggested tags:** Backend service (`tag_suggestions.py`) prompts LLM with existing user tags
|
||||||
and note content, returns 3-5 relevant tag suggestions. Tags already in body are filtered out.
|
and note content, returns 3-5 relevant tag suggestions filtered against `current_tags` (not body text).
|
||||||
Exposed via `POST /api/notes/suggest-tags` and `POST /api/notes/:id/append-tag`. Integrated in:
|
Exposed via `POST /api/notes/suggest-tags` (accepts `current_tags` list) and `POST /api/notes/:id/append-tag`.
|
||||||
(1) Editor views — "Suggest tags" button shows clickable pills that insert `#tag` into body;
|
Integrated in:
|
||||||
|
(1) Editor views — "Suggest tags" button shows clickable pills that add chips to TagInput (not body);
|
||||||
(2) Chat tool calls — `create_note`/`create_task` results include `suggested_tags`, rendered as
|
(2) Chat tool calls — `create_note`/`create_task` results include `suggested_tags`, rendered as
|
||||||
pills in ToolCallCard with one-click apply via append-tag API.
|
pills in ToolCallCard with one-click apply via append-tag API.
|
||||||
|
LLM instructed to use the `tags` parameter, not embed `#tag` text in note body.
|
||||||
|
|
||||||
### Authentication & User Management
|
### Authentication & User Management
|
||||||
- Session cookie auth with bcrypt, first-user-is-admin, orphaned data claiming
|
- Session cookie auth with bcrypt, first-user-is-admin, orphaned data claiming
|
||||||
|
|||||||
Reference in New Issue
Block a user