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
;
-}
+});
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 (
-
);
});
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 }),
}
}