Initial commit: note-taking/task-tracking app with LLM integration scaffold

Vue 3 + TypeScript frontend with Pinia stores, markdown rendering (marked + DOMPurify),
wikilink/tag linkification, and autocomplete. Quart async backend with SQLAlchemy 2.0,
PostgreSQL ARRAY columns, task-note companion linking, backlinks, and note-to-task
conversion. Docker Compose setup with PostgreSQL 16 and Ollama.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 23:35:44 -05:00
commit 22a3a3c1d1
71 changed files with 7173 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
import { marked } from "marked";
import DOMPurify from "dompurify";
import { linkifyTags, linkifyWikilinks } from "@/utils/tags";
export function renderMarkdown(text: string): string {
const html = marked(text) as string;
const withTags = linkifyTags(html);
const withLinks = linkifyWikilinks(withTags);
return DOMPurify.sanitize(withLinks, {
ADD_ATTR: ["data-tag", "data-title"],
});
}
export function renderPreview(text: string): string {
const html = marked(text) as string;
const withTags = linkifyTags(html);
const withLinks = linkifyWikilinks(withTags);
return DOMPurify.sanitize(withLinks, {
FORBID_TAGS: ["a", "img"],
});
}
+49
View File
@@ -0,0 +1,49 @@
const CODE_FENCE_RE = /```[\s\S]*?```|`[^`\n]+`/g;
const TAG_RE = /(?<!\w)#([\w]+(?:\/[\w]+)*)/g;
function escapeHtmlAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
export function extractTags(body: string): string[] {
const cleaned = body.replace(CODE_FENCE_RE, "");
const tags = new Set<string>();
let match;
while ((match = TAG_RE.exec(cleaned)) !== null) {
tags.add(match[1]);
}
TAG_RE.lastIndex = 0;
return [...tags].sort();
}
export function linkifyTags(html: string): string {
// Split on code/pre blocks to avoid linkifying inside them
const parts = html.split(/(<code[\s\S]*?<\/code>|<pre[\s\S]*?<\/pre>)/gi);
return parts
.map((part, i) => {
// Odd indices are code/pre blocks, skip them
if (i % 2 === 1) return part;
return part.replace(TAG_RE, (full, tag) => {
const encoded = encodeURIComponent(tag);
return `<a class="inline-tag" data-tag="${escapeHtmlAttr(tag)}" href="/notes?tag=${encoded}">${full}</a>`;
});
})
.join("");
}
const WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
export function linkifyWikilinks(html: string): string {
const parts = html.split(/(<code[\s\S]*?<\/code>|<pre[\s\S]*?<\/pre>)/gi);
return parts
.map((part, i) => {
if (i % 2 === 1) return part;
return part.replace(WIKILINK_RE, (_full, title: string, display?: string) => {
const trimmed = title.trim();
const label = display || trimmed;
const encoded = encodeURIComponent(trimmed);
return `<a class="wikilink" data-title="${escapeHtmlAttr(trimmed)}" href="/notes/by-title?title=${encoded}">${escapeHtmlAttr(label)}</a>`;
});
})
.join("");
}