editable todo summary field

This commit is contained in:
azykov@mail.ru
2026-08-25 22:38:07 +03:00
parent cfae2d91f4
commit 0fbfe2e861
12 changed files with 343 additions and 74 deletions
+11 -6
View File
@@ -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>;
}
});
+2 -2
View File
@@ -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) {
+14 -1
View File
@@ -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: "&nbsp";
display: inline-block;
margin: 0.25rem;
width: 0.5rem;
height: 0.5rem;
background: yellow;
}
}
+6 -6
View File
@@ -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
View File
@@ -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`;
}
+9
View File
@@ -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();
+65
View File
@@ -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;
});
}
}
+73
View File
@@ -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;
}
}
-44
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
}