Jacob Schmidt c7933aeeec Lists & todos backend, editor refactor, inline create, UX improvements
- Wire up lists and todos to C# backend with full CRUD persistence
- Add models, DTOs, repositories, and services for lists and todo lists/items
- Preserve SQLite DB across vault rebuild/load cycles
- Add session store for vault password persistence across navigation
- Add inline name input for creating lists and todo lists in SidePanel
- Clear editor panel on section change with empty state placeholder
- Default markdown editor to preview mode on item selection
- Decompose EditorPanel into sub-components:
  - editor/FragmentEditor, editor/TodoEditor, editor/MarkdownEditor
  - Shared markdown utilities in utils/markdown.ts
- Strip verbose console/eprintln logging from frontend and Tauri backend
- Add graceful shutdown with vault persistence on window close

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-02-26 17:33:27 -06:00

216 lines
6.3 KiB
TypeScript

import { get, writable } from "svelte/store";
import {
createFragment as createFragmentCommand,
deleteFragment as deleteFragmentCommand,
listFragments,
updateFragment as updateFragmentCommand,
type FragmentDto
} from "$lib/backend/fragments";
export type FragmentItem = {
id: string;
label: string;
initialContent: string;
};
export type ParsedFragment = {
title: string;
type: string;
tags: string[];
body: string;
};
const initialFragments: FragmentItem[] = [];
export const fragmentsStore = writable<FragmentItem[]>(initialFragments);
export const fragmentsBusyStore = writable(false);
function toStoreId(id: string): string {
return `fragments/${id}`;
}
function toBackendId(id: string): string | null {
const prefix = "fragments/";
if (!id.startsWith(prefix)) return null;
const backendId = id.slice(prefix.length).trim();
return backendId || null;
}
function splitDescription(description: string): { title: string; body: string } {
const normalized = description.trim();
if (!normalized) {
return { title: "Untitled Fragment", body: "" };
}
const separator = normalized.indexOf("\n\n");
if (separator === -1) {
return { title: normalized, body: "" };
}
const title = normalized.slice(0, separator).trim() || "Untitled Fragment";
const body = normalized.slice(separator + 2).trim();
return { title, body };
}
function composeDescription(title: string, body: string): string {
const resolvedTitle = title.trim() || "Untitled Fragment";
const resolvedBody = body.trim() || "Add details for this fragment.";
return `${resolvedTitle}\n\n${resolvedBody}`;
}
function dtoToItem(dto: FragmentDto): FragmentItem {
const parsed = splitDescription(dto.description);
return {
id: toStoreId(dto.id),
label: parsed.title,
initialContent: serializeFragment({
title: parsed.title,
type: dto.type,
tags: dto.tags ?? [],
body: parsed.body
})
};
}
function upsertById(items: FragmentItem[], next: FragmentItem): FragmentItem[] {
const idx = items.findIndex((item) => item.id === next.id);
if (idx === -1) {
return [next, ...items];
}
const clone = [...items];
clone[idx] = next;
return clone;
}
export function createFragmentId(title: string): string {
const slug = title
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
return `fragments/${slug || "fragment"}-${Date.now()}`;
}
export function serializeFragment(payload: ParsedFragment): string {
const title = payload.title.trim() || "Untitled Fragment";
const type = payload.type.trim();
const tagsLine = payload.tags.length ? payload.tags.map((tag) => `#${tag}`).join(" ") : "(none)";
const body = payload.body.trim() || "Add details for this fragment.";
return `# ${title}\n\nType: ${type}\nTags: ${tagsLine}\n\n${body}`;
}
export function parseFragmentContent(content: string, fallbackTitle = "Untitled Fragment"): ParsedFragment {
const headingMatch = content.match(/^#\s+(.+)$/m);
const typeMatch = content.match(/^Type:\s*(.+)$/m);
const tagsMatch = content.match(/^Tags:\s*(.+)$/m);
const bodyMatch = content.match(/^#.*\n\nType:.*\nTags:.*\n\n([\s\S]*)$/);
const rawTags = tagsMatch?.[1]?.trim() ?? "(none)";
const tags =
rawTags.toLowerCase() === "(none)"
? []
: rawTags
.split(/\s+/)
.map((tag) => tag.replace(/^#/, "").trim())
.filter(Boolean);
return {
title: headingMatch?.[1]?.trim() || fallbackTitle,
type: typeMatch?.[1]?.trim() || "",
tags,
body: bodyMatch?.[1]?.trim() || ""
};
}
export function createFragmentDraft(): FragmentItem {
const id = `fragments/new-${Date.now()}`;
return {
id,
label: "New Fragment",
initialContent: "# New Fragment\n\nType: \nTags: (none)\n\n"
};
}
export function createFragmentItem(title: string, content: string): FragmentItem {
return {
id: createFragmentId(title),
label: title.trim() || "Untitled Fragment",
initialContent: content
};
}
export function updateFragmentItem(items: FragmentItem[], id: string, title: string, content: string): FragmentItem[] {
return items.map((item) =>
item.id === id
? { ...item, label: title.trim() || "Untitled Fragment", initialContent: content }
: item
);
}
export function prependFragmentItem(items: FragmentItem[], item: FragmentItem): FragmentItem[] {
return [item, ...items];
}
export function removeFragmentItem(items: FragmentItem[], id: string): FragmentItem[] {
return items.filter((item) => item.id !== id);
}
export async function hydrateFragments(): Promise<void> {
fragmentsBusyStore.set(true);
try {
const items = await listFragments();
fragmentsStore.set(items.map(dtoToItem));
} catch (error) {
console.error("[fragments] hydrate:error", error);
throw error;
} finally {
fragmentsBusyStore.set(false);
}
}
export async function createFragmentFromParsed(payload: ParsedFragment): Promise<FragmentItem> {
const created = await createFragmentCommand({
type: payload.type.trim(),
description: composeDescription(payload.title, payload.body),
tags: payload.tags
});
const item = dtoToItem(created);
fragmentsStore.update((items) => prependFragmentItem(items, item));
return item;
}
export async function updateFragmentFromParsed(storeId: string, payload: ParsedFragment): Promise<FragmentItem | null> {
const backendId = toBackendId(storeId);
if (!backendId) return null;
const ok = await updateFragmentCommand(backendId, {
type: payload.type.trim(),
description: composeDescription(payload.title, payload.body),
tags: payload.tags
});
if (!ok) return null;
const item: FragmentItem = {
id: storeId,
label: payload.title.trim() || "Untitled Fragment",
initialContent: serializeFragment(payload)
};
fragmentsStore.update((items) => upsertById(items, item));
return item;
}
export async function deleteFragmentByStoreId(storeId: string): Promise<boolean> {
const backendId = toBackendId(storeId);
if (!backendId) return false;
const ok = await deleteFragmentCommand(backendId);
if (!ok) return false;
fragmentsStore.update((items) => removeFragmentItem(items, storeId));
return true;
}
export function hasFragment(storeId: string): boolean {
return get(fragmentsStore).some((item) => item.id === storeId);
}