editable todo summary field
This commit is contained in:
@@ -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 <div className="todo">
|
||||
return <div className={['todo', ...(todo.dirty ? ['dirty'] : [])].join(' ')}>
|
||||
<div className="bullet" />
|
||||
<div className="summary" title={todo.uid}>
|
||||
<input value={todo.summary} />
|
||||
<input
|
||||
value={todo.summary}
|
||||
onChange={(e) => store.runtime.modifyTodo(data, { summary: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="details">{JSON.stringify(raw, undefined, 4)}</div>
|
||||
{
|
||||
!!children?.length && <TodoList items={children} />
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<button type="button" onClick={() => void todoStore.refresh()} disabled={todoStore.loading}>
|
||||
{todoStore.loading ? "Refreshing…" : "Refresh"}
|
||||
<button type="button" onClick={() => void store.server.refresh()} disabled={store.server.loading}>
|
||||
{store.server.loading ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
{todoStore.error && <p>Failed to load todos: {todoStore.error}</p>}
|
||||
{!todoStore.error && <TodoList items={todoStore.items} />}
|
||||
{store.server.error && <p>Failed to load todos: {store.server.error}</p>}
|
||||
<TodoList items={store.runtime.items} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
+118
-8
@@ -31,19 +31,59 @@ export async function fetchTodos(): Promise<Todo[]> {
|
||||
|
||||
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<Todo> {
|
||||
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<string, string> = {
|
||||
"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<string, string>): Todo {
|
||||
function toTodo(fields: Record<string, string>, 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<string, string>): 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`;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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<Todo>) {
|
||||
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<void> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<string[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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();
|
||||
+12
-1
@@ -6,7 +6,18 @@ export type Todo = {
|
||||
due?: Date;
|
||||
priority?: number;
|
||||
parentUid?: string;
|
||||
children?: Todo[];
|
||||
|
||||
raw: Record<string, string>;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
+24
-6
@@ -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<string, Todo>();
|
||||
export function buildTodoTree(todos: Todo[]): RuntimeTodo[] {
|
||||
const nodes = new Map<string, RuntimeTodo>();
|
||||
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<RuntimeTodo> {
|
||||
for (const item of todos) {
|
||||
yield item;
|
||||
|
||||
if (item.children) {
|
||||
yield* traverseTodoTree(item.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function* filterTodoTree(todos: RuntimeTodo[], filter: (item: RuntimeTodo) => boolean): Iterable<RuntimeTodo> {
|
||||
for (const item of traverseTodoTree(todos)) {
|
||||
if (filter(item)) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user