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

268 lines
8.1 KiB
TypeScript

import { get, writable } from "svelte/store";
import {
createTodoItem as createTodoItemCommand,
createTodoList as createTodoListCommand,
deleteTodoItem as deleteTodoItemCommand,
deleteTodoList as deleteTodoListCommand,
listTodoLists,
updateTodoItem as updateTodoItemCommand,
updateTodoList as updateTodoListCommand,
type TodoListDto
} from "$lib/backend/todos";
// TodoItem keeps a numeric `id` for local array operations (used by EditorPanel)
// plus a `backendId` (guid string) for backend persistence.
export type TodoItem = { id: number; text: string; done: boolean; backendId?: string };
export type TodoListMeta = { id: string; label: string; backendId?: string };
export const todoListsStore = writable<TodoListMeta[]>([]);
export const todosStore = writable<Record<string, TodoItem[]>>({});
export const todosBusyStore = writable(false);
// ── ID helpers ───────────────────────────────────────────────────
function toStoreId(guid: string): string {
return `todos/${guid}`;
}
function toBackendId(storeId: string): string | null {
const prefix = "todos/";
if (!storeId.startsWith(prefix)) return null;
const backendId = storeId.slice(prefix.length).trim();
return backendId || null;
}
export function createTodoId(): number {
return Date.now() + Math.floor(Math.random() * 1000);
}
// ── DTO mapping ──────────────────────────────────────────────────
function dtoToMeta(dto: TodoListDto): TodoListMeta {
return {
id: toStoreId(dto.id),
label: dto.label,
backendId: dto.id
};
}
function dtoToItems(dto: TodoListDto): TodoItem[] {
return dto.items.map((item, index) => ({
id: createTodoId() + index,
text: item.text,
done: item.done,
backendId: item.id
}));
}
// ── Hydration ────────────────────────────────────────────────────
export async function hydrateTodos(): Promise<void> {
todosBusyStore.set(true);
try {
const lists = await listTodoLists();
const metas: TodoListMeta[] = lists.map(dtoToMeta);
const items: Record<string, TodoItem[]> = {};
for (const dto of lists) {
items[toStoreId(dto.id)] = dtoToItems(dto);
}
todoListsStore.set(metas);
todosStore.set(items);
} catch (error) {
console.error("[todos] hydrate:error", error);
throw error;
} finally {
todosBusyStore.set(false);
}
}
// ── List CRUD ────────────────────────────────────────────────────
export async function createTodoListFromLabel(
label: string
): Promise<{ meta: TodoListMeta; items: TodoItem[] }> {
const resolvedLabel = label.trim() || "New List";
const created = await createTodoListCommand({ label: resolvedLabel });
const meta = dtoToMeta(created);
todoListsStore.update((metas) => [meta, ...metas]);
todosStore.update((lists) => ({ ...lists, [meta.id]: [] }));
return { meta, items: [] };
}
export async function deleteTodoListByStoreId(storeId: string): Promise<boolean> {
const backendId = toBackendId(storeId);
if (!backendId) return false;
const ok = await deleteTodoListCommand(backendId);
if (!ok) return false;
todoListsStore.update((metas) => metas.filter((m) => m.id !== storeId));
todosStore.update((lists) => {
const { [storeId]: _, ...rest } = lists;
return rest;
});
return true;
}
// ── Item CRUD (backend-backed) ───────────────────────────────────
export async function addTodoItemBackend(
storeId: string,
text: string
): Promise<TodoItem | null> {
const backendListId = toBackendId(storeId);
if (!backendListId || !text.trim()) return null;
const items = get(todosStore)[storeId] ?? [];
const sortOrder = items.length;
const created = await createTodoItemCommand({
listId: backendListId,
text: text.trim(),
sortOrder
});
const item: TodoItem = {
id: createTodoId(),
text: created.text,
done: created.done,
backendId: created.id
};
todosStore.update((lists) => ({
...lists,
[storeId]: [item, ...(lists[storeId] ?? [])]
}));
return item;
}
export async function toggleTodoItemBackend(
storeId: string,
localId: number
): Promise<boolean> {
const items = get(todosStore)[storeId];
const todo = items?.find((t) => t.id === localId);
if (!todo?.backendId) return false;
const ok = await updateTodoItemCommand(todo.backendId, { done: !todo.done });
if (!ok) return false;
todosStore.update((lists) => ({
...lists,
[storeId]: (lists[storeId] ?? []).map((t) =>
t.id === localId ? { ...t, done: !t.done } : t
)
}));
return true;
}
export async function updateTodoItemTextBackend(
storeId: string,
localId: number,
text: string
): Promise<boolean> {
const items = get(todosStore)[storeId];
const todo = items?.find((t) => t.id === localId);
if (!todo?.backendId || !text.trim()) return false;
const ok = await updateTodoItemCommand(todo.backendId, { text: text.trim() });
if (!ok) return false;
todosStore.update((lists) => ({
...lists,
[storeId]: (lists[storeId] ?? []).map((t) =>
t.id === localId ? { ...t, text: text.trim() } : t
)
}));
return true;
}
export async function removeTodoItemBackend(
storeId: string,
localId: number
): Promise<boolean> {
const items = get(todosStore)[storeId];
const todo = items?.find((t) => t.id === localId);
if (!todo?.backendId) return false;
const ok = await deleteTodoItemCommand(todo.backendId);
if (!ok) return false;
todosStore.update((lists) => ({
...lists,
[storeId]: (lists[storeId] ?? []).filter((t) => t.id !== localId)
}));
return true;
}
// ── Pure helpers (used by EditorPanel for local state) ───────────
export function serializeTodoList(title: string, todos: TodoItem[]): string {
const heading = title?.trim() ? `# ${title}` : "# To-Do List";
const lines = todos.map((todo) => `- [${todo.done ? "x" : " "}] ${todo.text}`);
return `${heading}\n\n${lines.join("\n")}`;
}
export function parseTodoList(content: string): TodoItem[] {
const lines = content.replace(/\r\n/g, "\n").split("\n");
const parsed: TodoItem[] = [];
for (const line of lines) {
const match = line.match(/^- \[( |x)\]\s+(.+)$/i);
if (!match) continue;
parsed.push({
id: createTodoId(),
text: match[2].trim(),
done: match[1].toLowerCase() === "x"
});
}
return parsed;
}
export function getOrCreateTodoList(
lists: Record<string, TodoItem[]>,
documentId: string,
fallbackContent: string
): { lists: Record<string, TodoItem[]>; todos: TodoItem[] } {
const existing = lists[documentId];
if (existing) {
return { lists, todos: existing };
}
const parsed = parseTodoList(fallbackContent);
return { lists: { ...lists, [documentId]: parsed }, todos: parsed };
}
export function setTodoList(
lists: Record<string, TodoItem[]>,
documentId: string,
todos: TodoItem[]
): Record<string, TodoItem[]> {
return { ...lists, [documentId]: todos };
}
export function addTodoItem(todos: TodoItem[], text: string): TodoItem[] {
return [{ id: createTodoId(), text: text.trim(), done: false }, ...todos];
}
export function toggleTodoItem(todos: TodoItem[], id: number): TodoItem[] {
return todos.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo));
}
export function updateTodoItemText(todos: TodoItem[], id: number, text: string): TodoItem[] {
return todos.map((todo) => (todo.id === id ? { ...todo, text: text.trim() } : todo));
}
export function removeTodoItem(todos: TodoItem[], id: number): TodoItem[] {
return todos.filter((todo) => todo.id !== id);
}
export function createTodoListDraft(): { meta: TodoListMeta; items: TodoItem[] } {
const id = `todos/draft-${Date.now()}`;
return {
meta: { id, label: "New List" },
items: []
};
}