diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx index 365b8eb..ecfdb8e 100644 --- a/src/components/TodoItem.tsx +++ b/src/components/TodoItem.tsx @@ -1,23 +1,28 @@ -import type { Todo } from "../types"; +import { observer } from "mobx-react-lite"; +import type { RuntimeTodo } from "../types"; +import { store } from "../stores/RootStore"; import { TodoList } from "./TodoList"; type Props = { - data: Todo; + data: RuntimeTodo; } -export const TodoItem = function ({ data }: Props) { +export const TodoItem = observer(function TodoItem({ data }: Props) { const { children, ...todo } = data; const { SUMMARY, UID, ...raw } = todo.raw; - return
+ return
- + store.runtime.modifyTodo(data, { summary: e.target.value })} + />
{JSON.stringify(raw, undefined, 4)}
{ !!children?.length && }
; -} +}); diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx index 0edc754..80b228a 100644 --- a/src/components/TodoList.tsx +++ b/src/components/TodoList.tsx @@ -1,8 +1,8 @@ -import type { Todo } from "../types"; +import type { RuntimeTodo } from "../types"; import { TodoItem } from "./TodoItem"; type Props = { - items: Todo[]; + items: RuntimeTodo[]; } export const TodoList = function(props: Props) { diff --git a/src/components/TodoView.scss b/src/components/TodoView.scss index a4aa6eb..8329bd1 100644 --- a/src/components/TodoView.scss +++ b/src/components/TodoView.scss @@ -2,12 +2,25 @@ &>.todo { + &.dirty { + background: #ffff0040; + } + &>.bullet { display: inline-block; + width: 1rem; + height: 1rem; + background: red; + border-radius: 0.5rem; &::after { - content: "•"; + content: " "; + display: inline-block; + margin: 0.25rem; + width: 0.5rem; + height: 0.5rem; + background: yellow; } } diff --git a/src/components/TodoView.tsx b/src/components/TodoView.tsx index 8841994..64dc050 100644 --- a/src/components/TodoView.tsx +++ b/src/components/TodoView.tsx @@ -1,22 +1,22 @@ import { useEffect } from "react"; import { observer } from "mobx-react-lite"; -import { todoStore } from "../stores/TodoStore"; import { TodoList } from "./TodoList" import './TodoView.scss'; +import { store } from "../stores/RootStore"; export const TodoView = observer(function TodoView() { useEffect(() => { - void todoStore.refresh(); + void store.server.refresh(); }, []); return (
- - {todoStore.error &&

Failed to load todos: {todoStore.error}

} - {!todoStore.error && } + {store.server.error &&

Failed to load todos: {store.server.error}

} +
); }); diff --git a/src/services/caldav.ts b/src/services/caldav.ts index 624d230..06465b3 100644 --- a/src/services/caldav.ts +++ b/src/services/caldav.ts @@ -31,19 +31,59 @@ export async function fetchTodos(): Promise { const xml = await response.text(); const doc = new DOMParser().parseFromString(xml, "application/xml"); - const calendarDataNodes = doc.getElementsByTagNameNS("urn:ietf:params:xml:ns:caldav", "calendar-data"); + const responseNodes = doc.getElementsByTagNameNS("DAV:", "response"); const todos: Todo[] = []; - for (const node of Array.from(calendarDataNodes)) { - console.dir(node); - if (node.textContent) { - todos.push(...parseVTodos(node.textContent)); + for (const node of Array.from(responseNodes)) { + const href = node.getElementsByTagNameNS("DAV:", "href")[0]?.textContent; + const etag = node.getElementsByTagNameNS("DAV:", "getetag")[0]?.textContent ?? null; + const icsText = node.getElementsByTagNameNS("urn:ietf:params:xml:ns:caldav", "calendar-data")[0]?.textContent; + + if (href && icsText) { + todos.push(...parseVTodos(icsText, href, etag)); } } return todos; } -function parseVTodos(ics: string): Todo[] { +// Writes back only the property we actually let the user edit (SUMMARY), +// patching the todo's own last-fetched ICS text in place rather than +// rebuilding it from the flat `raw` fields — that map has already lost +// per-property parameters (VALUE=DATE, TZID, ...), so reconstructing from +// it would silently corrupt anything using them. +export async function saveTodo(todo: Todo): Promise { + const filename = todo.href.split("/").filter(Boolean).pop(); + if (!filename) { + throw new Error(`Cannot determine resource filename from href: ${todo.href}`); + } + + const icsText = patchIcsSummary(todo.icsText, todo.uid, todo.summary); + + const headers: Record = { + "Content-Type": "text/calendar; charset=utf-8", + }; + if (todo.etag) { + // Aborts the write instead of silently clobbering a change someone + // else made to this VTODO since we last fetched it. + headers["If-Match"] = todo.etag; + } + + const response = await fetch(`${CALDAV_PROXY_PATH}${filename}`, { + method: "PUT", + headers, + body: icsText, + }); + + if (!response.ok) { + throw new Error(`Failed to save "${todo.summary}": ${response.status} ${response.statusText}`); + } + + const etag = response.headers.get("ETag") ?? todo.etag; + const [saved] = parseVTodos(icsText, todo.href, etag); + return saved; +} + +function parseVTodos(ics: string, href: string, etag: string | null): Todo[] { // RFC 5545 line folding: continuation lines start with a space or tab. const lines = ics.replace(/\r\n/g, "\n").replace(/\n[ \t]/g, "").split("\n"); @@ -57,7 +97,7 @@ function parseVTodos(ics: string): Todo[] { } if (line === "END:VTODO") { if (current) { - todos.push(toTodo(current)); + todos.push(toTodo(current, ics, href, etag)); } current = null; continue; @@ -92,7 +132,7 @@ function parseVTodos(ics: string): Todo[] { return todos; } -function toTodo(fields: Record): Todo { +function toTodo(fields: Record, icsText: string, href: string, etag: string | null): Todo { return { uid: fields.UID ?? crypto.randomUUID(), summary: fields.SUMMARY ?? "", @@ -103,9 +143,66 @@ function toTodo(fields: Record): Todo { parentUid: fields.PARENT_UID, raw: fields, + href, + etag, + icsText, }; } +// Rewrites SUMMARY/SEQUENCE/DTSTAMP/LAST-MODIFIED in place within the VTODO +// block matching `uid`, leaving every other line byte-for-byte untouched. +function patchIcsSummary(icsText: string, uid: string, summary: string): string { + const lines = icsText.replace(/\r\n/g, "\n").split("\n"); + const block = findVTodoBlock(lines, uid); + if (!block) { + throw new Error(`VTODO with UID ${uid} not found in its own calendar resource`); + } + + const stamp = formatIcsDateUtc(new Date()); + let sawSummary = false; + let sawSequence = false; + + for (let i = block.start; i <= block.end; i++) { + const line = lines[i]; + if (line.startsWith("SUMMARY:") || line.startsWith("SUMMARY;")) { + lines[i] = `SUMMARY:${escapeIcsText(summary)}`; + sawSummary = true; + } else if (line.startsWith("SEQUENCE:")) { + const current = Number(line.slice("SEQUENCE:".length)) || 0; + lines[i] = `SEQUENCE:${current + 1}`; + sawSequence = true; + } else if (line.startsWith("DTSTAMP:")) { + lines[i] = `DTSTAMP:${stamp}`; + } else if (line.startsWith("LAST-MODIFIED:")) { + lines[i] = `LAST-MODIFIED:${stamp}`; + } + } + + if (!sawSequence) { + lines.splice(block.start + 1, 0, "SEQUENCE:1"); + } + if (!sawSummary) { + lines.splice(block.start + 1, 0, `SUMMARY:${escapeIcsText(summary)}`); + } + + return lines.join("\r\n"); +} + +function findVTodoBlock(lines: string[], uid: string): { start: number; end: number } | undefined { + let start = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i] === "BEGIN:VTODO") { + start = i; + } else if (lines[i] === "END:VTODO" && start !== -1) { + if (lines.slice(start, i + 1).includes(`UID:${uid}`)) { + return { start, end: i }; + } + start = -1; + } + } + return undefined; +} + function unescapeIcsText(value: string): string { return value .replace(/\\n/gi, "\n") @@ -114,6 +211,14 @@ function unescapeIcsText(value: string): string { .replace(/\\\\/g, "\\"); } +function escapeIcsText(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/\n/g, "\\n") + .replace(/,/g, "\\,") + .replace(/;/g, "\\;"); +} + function parseIcsDate(value: string): Date { const match = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(value); if (!match) { @@ -131,3 +236,8 @@ function parseIcsDate(value: string): Date { ? new Date(Date.UTC(year, month, day, hour, minute, second)) : new Date(year, month, day, hour, minute, second); } + +function formatIcsDateUtc(date: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`; +} diff --git a/src/stores/RootStore.ts b/src/stores/RootStore.ts new file mode 100644 index 0000000..eb4bf8c --- /dev/null +++ b/src/stores/RootStore.ts @@ -0,0 +1,9 @@ +import { RuntimeTodoStore } from "./RuntimeTodoStore"; +import { ServerTodoStore } from "./ServerTodoStore"; + +export class RootStore { + public readonly runtime = new RuntimeTodoStore(); + public readonly server = new ServerTodoStore(); +} + +export const store = new RootStore(); diff --git a/src/stores/RuntimeTodoStore.ts b/src/stores/RuntimeTodoStore.ts new file mode 100644 index 0000000..6379dca --- /dev/null +++ b/src/stores/RuntimeTodoStore.ts @@ -0,0 +1,65 @@ +import { makeAutoObservable, runInAction } from "mobx"; +import type { RuntimeTodo, Todo } from "../types"; +import { buildTodoTree, filterTodoTree } from "../utils/todoTree"; +import { store } from "./RootStore"; + +export class RuntimeTodoStore { + items: RuntimeTodo[] = []; + dirty: boolean = false; + private saveTimer: number | undefined; + + constructor() { + makeAutoObservable(this); + } + + public setFromServer(items: Todo[]) { + this.items = buildTodoTree(items); + } + + public modifyTodo(item: RuntimeTodo, mutation: Partial) { + runInAction(() => { + Object.assign(item, mutation); + + const serverItem = store.server.getItemByUid(item.uid); + const keys = Object.keys(mutation) as (keyof Todo)[]; + item.dirty = !serverItem || keys.some((key) => item[key] !== serverItem[key]); + + this.setDirty(); + }); + } + + private setDirty(value: boolean = true) { + this.dirty = value; + + if (this.saveTimer) + clearTimeout(this.saveTimer); + + this.saveTimer = setTimeout(this.saveToServer.bind(this), 3000); + } + + public async saveToServer(): Promise { + if (this.saveTimer) + clearTimeout(this.saveTimer); + + const dirtyItems = Array.from(filterTodoTree(this.items, (item) => item.dirty)); + if (dirtyItems.length === 0) { + return; + } + + // Snapshot what we're about to send: if a todo gets edited again + // while its save is in flight, the response no longer reflects its + // current state, so it must stay dirty (setDirty() already queued a + // fresh save for it). + const summarySnapshot = new Map(dirtyItems.map((item) => [item.uid, item.summary])); + const savedUids = new Set(await store.server.save(dirtyItems)); + + runInAction(() => { + for (const item of dirtyItems) { + if (savedUids.has(item.uid) && item.summary === summarySnapshot.get(item.uid)) { + item.dirty = false; + } + } + this.dirty = Array.from(filterTodoTree(this.items, (item) => item.dirty)).length > 0; + }); + } +} diff --git a/src/stores/ServerTodoStore.ts b/src/stores/ServerTodoStore.ts new file mode 100644 index 0000000..233235f --- /dev/null +++ b/src/stores/ServerTodoStore.ts @@ -0,0 +1,73 @@ +import { makeAutoObservable, runInAction } from "mobx"; +import type { Todo } from "../types"; +import { fetchTodos, saveTodo } from "../services/caldav"; +import { store } from "./RootStore"; + +export class ServerTodoStore { + items: Todo[] = []; + loading = false; + error: string | null = null; + + private requestId = 0; + + constructor() { + makeAutoObservable(this); + } + + async refresh(): Promise { + const id = ++this.requestId; + this.loading = true; + this.error = null; + + try { + const todos = await fetchTodos(); + if (id !== this.requestId) { + return; + } + runInAction(() => { + this.items = todos; + this.loading = false; + store.runtime.setFromServer(this.items); + }); + } catch (err) { + if (id !== this.requestId) { + return; + } + runInAction(() => { + this.error = err instanceof Error ? err.message : String(err); + this.loading = false; + }); + } + } + + getItemByUid(uid: string): Todo | undefined { + return this.items.find((item) => item.uid === uid); + } + + // Returns the uids that were actually persisted, so callers can tell + // successful writes apart from ones that failed and should stay dirty. + async save(items: Todo[]): Promise { + const savedUids: string[] = []; + + for (const item of items) { + try { + const saved = await saveTodo(item); + runInAction(() => { + const index = this.items.findIndex((i) => i.uid === saved.uid); + if (index === -1) { + this.items.push(saved); + } else { + this.items[index] = saved; + } + }); + savedUids.push(saved.uid); + } catch (err) { + runInAction(() => { + this.error = err instanceof Error ? err.message : String(err); + }); + } + } + + return savedUids; + } +} diff --git a/src/stores/TodoStore.ts b/src/stores/TodoStore.ts deleted file mode 100644 index 53ddf32..0000000 --- a/src/stores/TodoStore.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { makeAutoObservable, runInAction } from "mobx"; -import type { Todo } from "../types"; -import { fetchTodos } from "../services/caldav"; -import { buildTodoTree } from "../utils/todoTree"; - -export class TodoStore { - items: Todo[] = []; - loading = false; - error: string | null = null; - - private requestId = 0; - - constructor() { - makeAutoObservable(this); - } - - async refresh(): Promise { - const id = ++this.requestId; - this.loading = true; - this.error = null; - - try { - const todos = await fetchTodos(); - const tree = buildTodoTree(todos); - if (id !== this.requestId) { - return; - } - runInAction(() => { - this.items = tree; - this.loading = false; - }); - } catch (err) { - if (id !== this.requestId) { - return; - } - runInAction(() => { - this.error = err instanceof Error ? err.message : String(err); - this.loading = false; - }); - } - } -} - -export const todoStore = new TodoStore(); diff --git a/src/types/todo.ts b/src/types/todo.ts index dd1b564..e5b9b03 100644 --- a/src/types/todo.ts +++ b/src/types/todo.ts @@ -6,7 +6,18 @@ export type Todo = { due?: Date; priority?: number; parentUid?: string; - children?: Todo[]; raw: Record; + + // The exact CalDAV resource this VTODO came from: needed to PUT changes + // back to the same file and to patch it without touching properties/ + // parameters we don't parse (e.g. DUE;VALUE=DATE). + href: string; + etag: string | null; + icsText: string; +} + +export type RuntimeTodo = Todo & { + children?: RuntimeTodo[]; + dirty: boolean; } diff --git a/src/utils/todoTree.ts b/src/utils/todoTree.ts index 49eb7bc..fb7c3ef 100644 --- a/src/utils/todoTree.ts +++ b/src/utils/todoTree.ts @@ -1,12 +1,12 @@ -import type { Todo } from "../types"; +import type { RuntimeTodo, Todo } from "../types"; -export function buildTodoTree(todos: Todo[]): Todo[] { - const nodes = new Map(); +export function buildTodoTree(todos: Todo[]): RuntimeTodo[] { + const nodes = new Map(); for (const todo of todos) { - nodes.set(todo.uid, { ...todo, children: [] }); + nodes.set(todo.uid, { ...todo, children: [], dirty: false }); } - const roots: Todo[] = []; + const roots: RuntimeTodo[] = []; for (const node of nodes.values()) { const parent = node.parentUid ? nodes.get(node.parentUid) : undefined; if (parent && parent.uid !== node.uid && !isDescendant(node, parent.uid)) { @@ -23,6 +23,24 @@ export function buildTodoTree(todos: Todo[]): Todo[] { // Guards against a malformed RELATED-TO chain on the server forming a cycle, // which would otherwise make the tree recurse forever when rendered. -function isDescendant(node: Todo, uid: string): boolean { +function isDescendant(node: RuntimeTodo, uid: string): boolean { return node.children?.some((child) => child.uid === uid || isDescendant(child, uid)) ?? false; } + +export function* traverseTodoTree(todos: RuntimeTodo[]): Iterable { + for (const item of todos) { + yield item; + + if (item.children) { + yield* traverseTodoTree(item.children); + } + } +} + +export function* filterTodoTree(todos: RuntimeTodo[], filter: (item: RuntimeTodo) => boolean): Iterable { + for (const item of traverseTodoTree(todos)) { + if (filter(item)) { + yield item; + } + } +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 81071c8..9d334c5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,5 @@ +import { Agent as HttpAgent } from 'node:http' +import { Agent as HttpsAgent } from 'node:https' import react from '@vitejs/plugin-react' import { defineConfig, loadEnv, type ProxyOptions } from 'vite' @@ -23,6 +25,13 @@ export default defineConfig(({ mode }) => { changeOrigin: true, rewrite: (path) => path.replace(/^\/caldav/, basePath), headers, + // The CalDAV server answers every request with `Connection: close`, + // but http-proxy's default agent still pools/reuses sockets — that + // races with the server closing them and intermittently surfaces as + // a 503 (ECONNRESET) here. A fresh connection per request avoids it. + agent: target.protocol === 'https:' + ? new HttpsAgent({ keepAlive: false }) + : new HttpAgent({ keepAlive: false }), } }