diff --git a/README.md b/README.md new file mode 100644 index 0000000..722a914 --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# Fabled Assistant + +A self-hosted note-taking and task-tracking application with integrated LLM capabilities. Write, organize, and enhance your notes with the help of a local AI assistant — all running on your own hardware. + +## What It Does + +**Notes with inline formatting** — Write in Markdown with a live-preview editor. Headings, bold, italic, lists, and code blocks render inline as you type, similar to Obsidian or Typora. + +**Task tracking** — Any note can become a task with a status (todo, in progress, done), priority level, and due date. Convert freely between notes and tasks without losing content. + +**Wikilinks and backlinks** — Link notes together with `[[Title]]` syntax. Click a wikilink to navigate to (or auto-create) the referenced note. Each note shows what links to it. + +**Tag organization** — Add `#tags` anywhere in your text. Tags are extracted automatically and support hierarchical filtering (e.g., `#project/webapp` matches both `project` and `project/webapp`). Tag autocomplete suggests existing tags as you type. + +**AI writing assistant** — Select a section of your note and give the assistant an instruction ("summarize this", "make it more concise", "add examples"). The assistant streams a suggestion that you can accept or reject. Powered by Ollama running locally. + +**AI chat** — Have conversations with your AI assistant. The chat is context-aware: it can automatically find and reference your notes to give more relevant answers. Attach specific notes for focused discussions. Save useful responses as new notes. + +**Multi-user support** — Multiple users can share the same instance with isolated data. The first registered user becomes the admin. + +**Dark and light themes** — Defaults to dark mode with a one-click toggle. All views respect your preference. + +## Getting Started + +### Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) and Docker Compose +- A machine with enough resources to run an LLM (8GB+ RAM recommended for smaller models) + +### Quick Start + +1. Clone the repository: + ```bash + git clone https://github.com/your-username/fabledassistant.git + cd fabledassistant + ``` + +2. Start the application: + ```bash + docker compose up --build + ``` + +3. Open `http://localhost:5000` in your browser. + +4. Register the first user account (this account becomes the admin). + +5. Go to **Settings** to download an LLM model. Smaller models like `llama3.2` (2GB) work well for getting started. + +### Day-to-Day Usage + +- **Create a note** from the Notes page. Use Markdown — formatting renders live in the editor. +- **Link notes** by typing `[[` to get a wikilink autocomplete dropdown. +- **Tag your notes** by typing `#` followed by a tag name. Autocomplete suggests existing tags. +- **Use the AI assistant** in the bottom panel of the editor. Select a section, write an instruction, and click Generate. +- **Chat with the AI** from the Chat page. Attach notes for context or let the assistant find relevant notes automatically. +- **Convert notes to tasks** from the note viewer to add status, priority, and due dates. +- **Save chat responses** as notes to preserve useful AI-generated content. +- **Backup your data** from Settings (admin users can export and restore full backups). + +## Configuration + +Configuration is done via environment variables. See `docker-compose.yml` for defaults. + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_URL` | `postgresql+asyncpg://...` | PostgreSQL connection string | +| `SECRET_KEY` | (required) | Session signing key | +| `OLLAMA_BASE_URL` | `http://ollama:11434` | Ollama API endpoint | +| `DEFAULT_MODEL` | `llama3.1` | Default LLM model to auto-pull on startup | +| `LOG_LEVEL` | `INFO` | Logging level | + +For production deployments, `docker-compose.prod.yml` supports Docker secrets (`SECRET_KEY_FILE`, `DATABASE_URL_FILE`) and includes network isolation, health checks, and resource limits. + +## Technical Overview + +### Stack + +| Layer | Technology | +|-------|------------| +| Frontend | Vue 3, TypeScript, Vite, Pinia, Vue Router | +| Editor | Tiptap (ProseMirror) with custom extensions | +| Backend | Python 3.12, Quart (async Flask-like framework) | +| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic migrations | +| LLM | Ollama (or any OpenAI-compatible API) | +| Deployment | Docker Compose (app + PostgreSQL + Ollama) | + +### Architecture + +The application runs as a single container serving both the Vue.js SPA and the REST API. The frontend is built by Vite during the Docker image build and served as static files by Quart. API routes live under `/api/`. + +LLM interactions use Server-Sent Events (SSE) for streaming responses. Chat generation runs in background `asyncio` tasks with an in-memory event buffer that supports client reconnection without data loss. + +The editor uses a Markdown-to-HTML-to-Markdown round-trip: content is stored as Markdown, converted to HTML for Tiptap editing, and serialized back to Markdown on every change. ProseMirror decoration plugins provide visual highlighting for tags and wikilinks without custom node types. + +### Database + +All data is stored in PostgreSQL. The schema uses a unified model where tasks are notes with additional attributes (`status`, `priority`, `due_date`). Migrations are idempotent raw SQL and run automatically on container startup. + +### Project Structure + +``` +fabledassistant/ +├── docker-compose.yml # Development stack +├── docker-compose.prod.yml # Production stack (Docker Swarm) +├── Dockerfile # Multi-stage build (Node + Python) +├── alembic/ # Database migrations +├── src/fabledassistant/ # Python backend +│ ├── app.py # Quart app factory +│ ├── models/ # SQLAlchemy models +│ ├── routes/ # API route blueprints +│ ├── services/ # Business logic +│ └── static/ # Built frontend (generated) +└── frontend/ # Vue.js frontend + └── src/ + ├── views/ # Page components + ├── components/ # Reusable UI components + ├── extensions/ # Tiptap/ProseMirror plugins + ├── composables/ # Vue composables + ├── stores/ # Pinia state management + └── api/ # API client +``` + +### Development + +All development is done via Docker: + +```bash +# Start the dev stack +docker compose up --build + +# Reset database (destroy volumes and rebuild) +docker compose down -v && docker compose up --build +``` + +No local dependency installation required. + +## License + +This project is privately maintained. diff --git a/frontend/package.json b/frontend/package.json index a40e271..34f3a94 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,12 @@ "preview": "vite preview" }, "dependencies": { + "@tiptap/extension-link": "^2.11.0", + "@tiptap/extension-placeholder": "^2.11.0", + "@tiptap/pm": "^2.11.0", + "@tiptap/starter-kit": "^2.11.0", + "@tiptap/suggestion": "^2.11.0", + "@tiptap/vue-3": "^2.11.0", "pinia": "^2.2.0", "vue": "^3.5.0", "marked": "^15.0.0", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 119497b..80d99b1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,8 +1,6 @@ @@ -21,9 +81,9 @@ const buttons = [ @@ -50,4 +110,9 @@ const buttons = [ .md-btn:hover { background: var(--color-bg-secondary); } +.md-btn.active { + background: var(--color-primary); + color: #fff; + border-color: var(--color-primary); +} diff --git a/frontend/src/components/SuggestionDropdown.vue b/frontend/src/components/SuggestionDropdown.vue new file mode 100644 index 0000000..bb00933 --- /dev/null +++ b/frontend/src/components/SuggestionDropdown.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/frontend/src/components/TiptapEditor.vue b/frontend/src/components/TiptapEditor.vue new file mode 100644 index 0000000..a6cd0e5 --- /dev/null +++ b/frontend/src/components/TiptapEditor.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/frontend/src/composables/useAssist.ts b/frontend/src/composables/useAssist.ts new file mode 100644 index 0000000..ca84f30 --- /dev/null +++ b/frontend/src/composables/useAssist.ts @@ -0,0 +1,204 @@ +import { ref, computed, watch, type Ref } from "vue"; +import { apiStreamPost } from "@/api/client"; +import { useChatStore } from "@/stores/chat"; +import { + parseMarkdownSections, + type MarkdownSection, +} from "@/utils/sectionParser"; + +export type AssistState = "idle" | "streaming" | "review"; + +export interface AssistTarget { + text: string; + startOffset: number; + endOffset: number; +} + +export function useAssist(body: Ref) { + const chatStore = useChatStore(); + + const state = ref("idle"); + const sections = ref([]); + const selectedSection = ref(null); + const customSelection = ref<{ start: number; end: number; text: string } | null>(null); + const instruction = ref(""); + const streamingText = ref(""); + const proposedText = ref(""); + const error = ref(""); + + // Snapshot of body at the time a section/selection was chosen + let bodySnapshot = ""; + + const target = computed(() => { + if (customSelection.value) { + return { + text: customSelection.value.text, + startOffset: customSelection.value.start, + endOffset: customSelection.value.end, + }; + } + if (selectedSection.value) { + return { + text: selectedSection.value.content, + startOffset: selectedSection.value.startOffset, + endOffset: selectedSection.value.endOffset, + }; + } + return null; + }); + + const canSubmit = computed( + () => + target.value !== null && + instruction.value.trim().length > 0 && + chatStore.chatReady && + state.value !== "streaming" + ); + + function refreshSections() { + sections.value = parseMarkdownSections(body.value); + } + + function selectSection(section: MarkdownSection) { + customSelection.value = null; + selectedSection.value = section; + bodySnapshot = body.value; + error.value = ""; + } + + function selectTextRange(start: number, end: number) { + if (start === end) return; + selectedSection.value = null; + customSelection.value = { + start, + end, + text: body.value.slice(start, end), + }; + bodySnapshot = body.value; + error.value = ""; + } + + function clearSelection() { + selectedSection.value = null; + customSelection.value = null; + instruction.value = ""; + streamingText.value = ""; + proposedText.value = ""; + error.value = ""; + state.value = "idle"; + } + + async function submit() { + if (!canSubmit.value || !target.value) return; + + state.value = "streaming"; + streamingText.value = ""; + proposedText.value = ""; + error.value = ""; + + try { + await apiStreamPost( + "/api/notes/assist", + { + body: body.value, + target_section: target.value.text, + instruction: instruction.value, + }, + (data) => { + if (data.chunk) { + streamingText.value += data.chunk as string; + } + if (data.done) { + proposedText.value = (data.full_text as string) || streamingText.value; + state.value = "review"; + } + if (data.error) { + error.value = data.error as string; + state.value = "idle"; + } + } + ); + // If stream ended without a done event, use accumulated text + if (state.value === "streaming") { + if (streamingText.value) { + proposedText.value = streamingText.value; + state.value = "review"; + } else { + state.value = "idle"; + } + } + } catch (e) { + error.value = e instanceof Error ? e.message : "Request failed"; + state.value = "idle"; + } + } + + function accept(): string { + if (!target.value || !proposedText.value) return body.value; + + const t = target.value; + + // Validate that the body hasn't changed at the target offsets + if (body.value !== bodySnapshot) { + const currentSlice = body.value.slice(t.startOffset, t.endOffset); + if (currentSlice !== t.text) { + error.value = "The document changed since this suggestion was made. Please clear and try again."; + return body.value; + } + } + + // Ensure proposed text ends with a newline for clean separation + let text = proposedText.value; + if (!text.endsWith("\n")) { + text += "\n"; + } + + const newBody = + body.value.slice(0, t.startOffset) + + text + + body.value.slice(t.endOffset); + + // Reset state + selectedSection.value = null; + customSelection.value = null; + streamingText.value = ""; + proposedText.value = ""; + error.value = ""; + state.value = "idle"; + + return newBody; + } + + function reject() { + proposedText.value = ""; + streamingText.value = ""; + error.value = ""; + state.value = "idle"; + // Keep selection + instruction for retry + } + + // Keep sections in sync with body automatically + watch(body, () => { + refreshSections(); + }, { immediate: true }); + + return { + state, + sections, + selectedSection, + customSelection, + target, + instruction, + streamingText, + proposedText, + error, + canSubmit, + refreshSections, + selectSection, + selectTextRange, + clearSelection, + submit, + accept, + reject, + }; +} diff --git a/frontend/src/extensions/TagDecoration.ts b/frontend/src/extensions/TagDecoration.ts new file mode 100644 index 0000000..7512a95 --- /dev/null +++ b/frontend/src/extensions/TagDecoration.ts @@ -0,0 +1,35 @@ +import { Extension } from "@tiptap/vue-3"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +const TAG_RE = /(?<=\s|^)#([\w]+(?:\/[\w]+)*)/g; + +export const TagDecoration = Extension.create({ + name: "tagDecoration", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("tagDecoration"), + props: { + decorations(state) { + const decorations: Decoration[] = []; + state.doc.descendants((node, pos) => { + if (!node.isText || !node.text) return; + TAG_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = TAG_RE.exec(node.text)) !== null) { + const from = pos + match.index; + const to = from + match[0].length; + decorations.push( + Decoration.inline(from, to, { class: "inline-tag" }) + ); + } + }); + return DecorationSet.create(state.doc, decorations); + }, + }, + }), + ]; + }, +}); diff --git a/frontend/src/extensions/TagSuggestion.ts b/frontend/src/extensions/TagSuggestion.ts new file mode 100644 index 0000000..eed4664 --- /dev/null +++ b/frontend/src/extensions/TagSuggestion.ts @@ -0,0 +1,61 @@ +import { Extension } from "@tiptap/vue-3"; +import { PluginKey } from "@tiptap/pm/state"; +import Suggestion from "@tiptap/suggestion"; +import { createSuggestionRenderer } from "./suggestionRenderer"; +import type { SuggestionItem } from "@/components/SuggestionDropdown.vue"; + +export interface TagSuggestionOptions { + fetchTags: (query: string) => Promise; +} + +const tagSuggestionPluginKey = new PluginKey("tagSuggestion"); + +export const TagSuggestion = Extension.create({ + name: "tagSuggestion", + + addOptions() { + return { + fetchTags: async () => [], + }; + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + pluginKey: tagSuggestionPluginKey, + char: "#", + allowSpaces: false, + startOfLine: false, + allow: ({ state, range }) => { + // Don't trigger at the start of a text block — the user is + // likely typing a heading (# / ## / ### …), not a tag. + const $from = state.doc.resolve(range.from); + const textBefore = $from.parent.textContent.slice(0, $from.parentOffset); + if (/^#*$/.test(textBefore)) return false; + return true; + }, + items: async ({ query }): Promise => { + try { + const tags = await this.options.fetchTags(query); + return tags.slice(0, 8).map((t) => ({ + label: `#${t}`, + value: t, + })); + } catch { + return []; + } + }, + command: ({ editor, range, props: item }) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertContent(`#${item.value} `) + .run(); + }, + render: createSuggestionRenderer, + }), + ]; + }, +}); diff --git a/frontend/src/extensions/WikilinkDecoration.ts b/frontend/src/extensions/WikilinkDecoration.ts new file mode 100644 index 0000000..c2096b3 --- /dev/null +++ b/frontend/src/extensions/WikilinkDecoration.ts @@ -0,0 +1,35 @@ +import { Extension } from "@tiptap/vue-3"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +const WIKILINK_RE = /\[\[([^\]]+)\]\]/g; + +export const WikilinkDecoration = Extension.create({ + name: "wikilinkDecoration", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("wikilinkDecoration"), + props: { + decorations(state) { + const decorations: Decoration[] = []; + state.doc.descendants((node, pos) => { + if (!node.isText || !node.text) return; + WIKILINK_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = WIKILINK_RE.exec(node.text)) !== null) { + const from = pos + match.index; + const to = from + match[0].length; + decorations.push( + Decoration.inline(from, to, { class: "wikilink" }) + ); + } + }); + return DecorationSet.create(state.doc, decorations); + }, + }, + }), + ]; + }, +}); diff --git a/frontend/src/extensions/WikilinkSuggestion.ts b/frontend/src/extensions/WikilinkSuggestion.ts new file mode 100644 index 0000000..7b53bd0 --- /dev/null +++ b/frontend/src/extensions/WikilinkSuggestion.ts @@ -0,0 +1,49 @@ +import { Extension } from "@tiptap/vue-3"; +import { PluginKey } from "@tiptap/pm/state"; +import Suggestion from "@tiptap/suggestion"; +import { createSuggestionRenderer } from "./suggestionRenderer"; +import { apiGet } from "@/api/client"; +import type { SuggestionItem } from "@/components/SuggestionDropdown.vue"; + +interface NoteListResponse { + notes: Array<{ id: number; title: string }>; +} + +const wikilinkSuggestionPluginKey = new PluginKey("wikilinkSuggestion"); + +export const WikilinkSuggestion = Extension.create({ + name: "wikilinkSuggestion", + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + pluginKey: wikilinkSuggestionPluginKey, + char: "[[", + allowSpaces: true, + items: async ({ query }): Promise => { + try { + const data = await apiGet( + `/api/notes?q=${encodeURIComponent(query)}&limit=8` + ); + return data.notes.map((n) => ({ + label: n.title || "Untitled", + value: n.title || "Untitled", + })); + } catch { + return []; + } + }, + command: ({ editor, range, props: item }) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertContent(`[[${item.value}]]`) + .run(); + }, + render: createSuggestionRenderer, + }), + ]; + }, +}); diff --git a/frontend/src/extensions/suggestionRenderer.ts b/frontend/src/extensions/suggestionRenderer.ts new file mode 100644 index 0000000..fd40adc --- /dev/null +++ b/frontend/src/extensions/suggestionRenderer.ts @@ -0,0 +1,79 @@ +import { createApp, type App } from "vue"; +import SuggestionDropdown from "@/components/SuggestionDropdown.vue"; +import type { SuggestionItem } from "@/components/SuggestionDropdown.vue"; +import type { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion"; + +interface DropdownExposed { + onKeyDown: (e: KeyboardEvent) => boolean; +} + +export function createSuggestionRenderer() { + let app: App | null = null; + let el: HTMLElement | null = null; + let componentRef: DropdownExposed | null = null; + let commandFn: ((item: SuggestionItem) => void) | null = null; + + function mountDropdown(items: SuggestionItem[]) { + if (!el) return; + if (app) app.unmount(); + app = createApp(SuggestionDropdown, { + items, + command: (item: SuggestionItem) => commandFn?.(item), + }); + const instance = app.mount(el); + componentRef = instance as unknown as DropdownExposed; + } + + return { + onStart(props: SuggestionProps) { + commandFn = (item: SuggestionItem) => props.command(item); + + el = document.createElement("div"); + el.style.position = "fixed"; + el.style.zIndex = "100"; + document.body.appendChild(el); + + mountDropdown(props.items as SuggestionItem[]); + updatePosition(props); + }, + + onUpdate(props: SuggestionProps) { + commandFn = (item: SuggestionItem) => props.command(item); + mountDropdown(props.items as SuggestionItem[]); + updatePosition(props); + }, + + onKeyDown(props: SuggestionKeyDownProps) { + if (props.event.key === "Escape") { + cleanup(); + return true; + } + return componentRef?.onKeyDown(props.event) ?? false; + }, + + onExit() { + cleanup(); + }, + }; + + function updatePosition(props: SuggestionProps) { + if (!el || !props.clientRect) return; + const rect = props.clientRect(); + if (!rect) return; + el.style.top = rect.bottom + "px"; + el.style.left = rect.left + "px"; + } + + function cleanup() { + if (app) { + app.unmount(); + app = null; + } + if (el) { + el.remove(); + el = null; + } + componentRef = null; + commandFn = null; + } +} diff --git a/frontend/src/utils/markdownSerializer.ts b/frontend/src/utils/markdownSerializer.ts new file mode 100644 index 0000000..963939b --- /dev/null +++ b/frontend/src/utils/markdownSerializer.ts @@ -0,0 +1,183 @@ +import type { JSONContent } from "@tiptap/vue-3"; + +export function serializeToMarkdown(doc: JSONContent): string { + if (doc.type !== "doc") return ""; + return serializeNodes(doc.content || []).replace(/\n{3,}/g, "\n\n").trimEnd(); +} + +function serializeNodes(nodes: JSONContent[], listIndent = 0): string { + let out = ""; + for (let i = 0; i < nodes.length; i++) { + out += serializeNode(nodes[i], listIndent); + } + return out; +} + +function serializeNode(node: JSONContent, listIndent = 0): string { + switch (node.type) { + case "paragraph": + return serializeInline(node.content || []) + "\n\n"; + + case "heading": { + const level = node.attrs?.level ?? 1; + const prefix = "#".repeat(level) + " "; + return prefix + serializeInline(node.content || []) + "\n\n"; + } + + case "bulletList": + return ( + serializeBulletList(node.content || [], listIndent) + + (listIndent === 0 ? "\n" : "") + ); + + case "orderedList": + return ( + serializeOrderedList(node.content || [], listIndent, node.attrs?.start ?? 1) + + (listIndent === 0 ? "\n" : "") + ); + + case "listItem": + // Handled by bulletList/orderedList + return ""; + + case "codeBlock": { + const lang = node.attrs?.language || ""; + const code = getTextContent(node); + return "```" + lang + "\n" + code + "\n```\n\n"; + } + + case "blockquote": { + const inner = serializeNodes(node.content || []); + return ( + inner + .trimEnd() + .split("\n") + .map((line) => "> " + line) + .join("\n") + "\n\n" + ); + } + + case "horizontalRule": + return "---\n\n"; + + case "hardBreak": + return "\n"; + + case "text": + return applyMarks(node.text || "", node.marks || []); + + default: + // Unknown node — try to serialize children + if (node.content) { + return serializeNodes(node.content); + } + return node.text || ""; + } +} + +function serializeBulletList( + items: JSONContent[], + indent: number +): string { + let out = ""; + for (const item of items) { + if (item.type !== "listItem") continue; + const children = item.content || []; + const prefix = " ".repeat(indent) + "- "; + out += serializeListItem(children, prefix, indent); + } + return out; +} + +function serializeOrderedList( + items: JSONContent[], + indent: number, + start: number +): string { + let out = ""; + let num = start; + for (const item of items) { + if (item.type !== "listItem") continue; + const children = item.content || []; + const prefix = " ".repeat(indent) + num + ". "; + out += serializeListItem(children, prefix, indent); + num++; + } + return out; +} + +function serializeListItem( + children: JSONContent[], + prefix: string, + indent: number +): string { + let out = ""; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.type === "paragraph") { + if (i === 0) { + out += prefix + serializeInline(child.content || []) + "\n"; + } else { + out += + " ".repeat(indent + 1) + + serializeInline(child.content || []) + + "\n"; + } + } else if (child.type === "bulletList") { + out += serializeBulletList(child.content || [], indent + 1); + } else if (child.type === "orderedList") { + out += serializeOrderedList( + child.content || [], + indent + 1, + child.attrs?.start ?? 1 + ); + } else { + out += serializeNode(child, indent + 1); + } + } + return out; +} + +function serializeInline(nodes: JSONContent[]): string { + let out = ""; + for (const node of nodes) { + if (node.type === "text") { + out += applyMarks(node.text || "", node.marks || []); + } else if (node.type === "hardBreak") { + out += "\n"; + } else if (node.content) { + out += serializeInline(node.content); + } + } + return out; +} + +function applyMarks(text: string, marks: NonNullable): string { + let result = text; + for (const mark of marks) { + switch (mark.type) { + case "bold": + result = `**${result}**`; + break; + case "italic": + result = `_${result}_`; + break; + case "strike": + result = `~~${result}~~`; + break; + case "code": + result = "`" + result + "`"; + break; + case "link": + result = `[${result}](${mark.attrs?.href || ""})`; + break; + } + } + return result; +} + +function getTextContent(node: JSONContent): string { + if (node.text) return node.text; + if (!node.content) return ""; + return node.content.map(getTextContent).join(""); +} diff --git a/frontend/src/utils/sectionParser.ts b/frontend/src/utils/sectionParser.ts new file mode 100644 index 0000000..9a23424 --- /dev/null +++ b/frontend/src/utils/sectionParser.ts @@ -0,0 +1,62 @@ +export interface MarkdownSection { + heading: string; + level: number; + content: string; + startOffset: number; + endOffset: number; +} + +const HEADING_RE = /^(#{1,6})\s+(.*)$/gm; + +export function parseMarkdownSections(body: string): MarkdownSection[] { + const sections: MarkdownSection[] = []; + const matches: { index: number; level: number; heading: string }[] = []; + + let match: RegExpExecArray | null; + while ((match = HEADING_RE.exec(body)) !== null) { + matches.push({ + index: match.index, + level: match[1].length, + heading: match[0], + }); + } + + // Preamble: text before the first heading + if (matches.length === 0) { + // Entire body is a single preamble section + if (body.length > 0) { + sections.push({ + heading: "", + level: 0, + content: body, + startOffset: 0, + endOffset: body.length, + }); + } + return sections; + } + + if (matches[0].index > 0) { + sections.push({ + heading: "", + level: 0, + content: body.slice(0, matches[0].index), + startOffset: 0, + endOffset: matches[0].index, + }); + } + + for (let i = 0; i < matches.length; i++) { + const start = matches[i].index; + const end = i + 1 < matches.length ? matches[i + 1].index : body.length; + sections.push({ + heading: matches[i].heading, + level: matches[i].level, + content: body.slice(start, end), + startOffset: start, + endOffset: end, + }); + } + + return sections; +} diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue index 4ac7379..da10592 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/views/ChatView.vue @@ -479,7 +479,7 @@ function excludeAutoNote(noteId: number) { diff --git a/frontend/src/views/NoteViewerView.vue b/frontend/src/views/NoteViewerView.vue index 59d0474..803bf97 100644 --- a/frontend/src/views/NoteViewerView.vue +++ b/frontend/src/views/NoteViewerView.vue @@ -162,7 +162,7 @@ async function convertToTask() { diff --git a/frontend/src/views/TaskViewerView.vue b/frontend/src/views/TaskViewerView.vue index 0787704..8b43a33 100644 --- a/frontend/src/views/TaskViewerView.vue +++ b/frontend/src/views/TaskViewerView.vue @@ -192,7 +192,7 @@ function onTagClick(tag: string) {