- 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>
268 lines
8.1 KiB
TypeScript
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: []
|
|
};
|
|
}
|