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";
|
import { TodoList } from "./TodoList";
|
||||||
|
|
||||||
type Props = {
|
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 { children, ...todo } = data;
|
||||||
|
|
||||||
const { SUMMARY, UID, ...raw } = todo.raw;
|
const { SUMMARY, UID, ...raw } = todo.raw;
|
||||||
|
|
||||||
return <div className="todo">
|
return <div className={['todo', ...(todo.dirty ? ['dirty'] : [])].join(' ')}>
|
||||||
<div className="bullet" />
|
<div className="bullet" />
|
||||||
<div className="summary" title={todo.uid}>
|
<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>
|
||||||
<div className="details">{JSON.stringify(raw, undefined, 4)}</div>
|
<div className="details">{JSON.stringify(raw, undefined, 4)}</div>
|
||||||
{
|
{
|
||||||
!!children?.length && <TodoList items={children} />
|
!!children?.length && <TodoList items={children} />
|
||||||
}
|
}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Todo } from "../types";
|
import type { RuntimeTodo } from "../types";
|
||||||
import { TodoItem } from "./TodoItem";
|
import { TodoItem } from "./TodoItem";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
items: Todo[];
|
items: RuntimeTodo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TodoList = function(props: Props) {
|
export const TodoList = function(props: Props) {
|
||||||
|
|||||||
@@ -2,12 +2,25 @@
|
|||||||
|
|
||||||
&>.todo {
|
&>.todo {
|
||||||
|
|
||||||
|
&.dirty {
|
||||||
|
background: #ffff0040;
|
||||||
|
}
|
||||||
|
|
||||||
&>.bullet {
|
&>.bullet {
|
||||||
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
background: red;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
|
||||||
&::after {
|
&::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 { useEffect } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { todoStore } from "../stores/TodoStore";
|
|
||||||
import { TodoList } from "./TodoList"
|
import { TodoList } from "./TodoList"
|
||||||
|
|
||||||
import './TodoView.scss';
|
import './TodoView.scss';
|
||||||
|
import { store } from "../stores/RootStore";
|
||||||
|
|
||||||
export const TodoView = observer(function TodoView() {
|
export const TodoView = observer(function TodoView() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void todoStore.refresh();
|
void store.server.refresh();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button type="button" onClick={() => void todoStore.refresh()} disabled={todoStore.loading}>
|
<button type="button" onClick={() => void store.server.refresh()} disabled={store.server.loading}>
|
||||||
{todoStore.loading ? "Refreshing…" : "Refresh"}
|
{store.server.loading ? "Refreshing…" : "Refresh"}
|
||||||
</button>
|
</button>
|
||||||
{todoStore.error && <p>Failed to load todos: {todoStore.error}</p>}
|
{store.server.error && <p>Failed to load todos: {store.server.error}</p>}
|
||||||
{!todoStore.error && <TodoList items={todoStore.items} />}
|
<TodoList items={store.runtime.items} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
+118
-8
@@ -31,19 +31,59 @@ export async function fetchTodos(): Promise<Todo[]> {
|
|||||||
|
|
||||||
const xml = await response.text();
|
const xml = await response.text();
|
||||||
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
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[] = [];
|
const todos: Todo[] = [];
|
||||||
for (const node of Array.from(calendarDataNodes)) {
|
for (const node of Array.from(responseNodes)) {
|
||||||
console.dir(node);
|
const href = node.getElementsByTagNameNS("DAV:", "href")[0]?.textContent;
|
||||||
if (node.textContent) {
|
const etag = node.getElementsByTagNameNS("DAV:", "getetag")[0]?.textContent ?? null;
|
||||||
todos.push(...parseVTodos(node.textContent));
|
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;
|
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.
|
// 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");
|
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 (line === "END:VTODO") {
|
||||||
if (current) {
|
if (current) {
|
||||||
todos.push(toTodo(current));
|
todos.push(toTodo(current, ics, href, etag));
|
||||||
}
|
}
|
||||||
current = null;
|
current = null;
|
||||||
continue;
|
continue;
|
||||||
@@ -92,7 +132,7 @@ function parseVTodos(ics: string): Todo[] {
|
|||||||
return todos;
|
return todos;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toTodo(fields: Record<string, string>): Todo {
|
function toTodo(fields: Record<string, string>, icsText: string, href: string, etag: string | null): Todo {
|
||||||
return {
|
return {
|
||||||
uid: fields.UID ?? crypto.randomUUID(),
|
uid: fields.UID ?? crypto.randomUUID(),
|
||||||
summary: fields.SUMMARY ?? "",
|
summary: fields.SUMMARY ?? "",
|
||||||
@@ -103,9 +143,66 @@ function toTodo(fields: Record<string, string>): Todo {
|
|||||||
parentUid: fields.PARENT_UID,
|
parentUid: fields.PARENT_UID,
|
||||||
|
|
||||||
raw: fields,
|
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 {
|
function unescapeIcsText(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/\\n/gi, "\n")
|
.replace(/\\n/gi, "\n")
|
||||||
@@ -114,6 +211,14 @@ function unescapeIcsText(value: string): string {
|
|||||||
.replace(/\\\\/g, "\\");
|
.replace(/\\\\/g, "\\");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeIcsText(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/\n/g, "\\n")
|
||||||
|
.replace(/,/g, "\\,")
|
||||||
|
.replace(/;/g, "\\;");
|
||||||
|
}
|
||||||
|
|
||||||
function parseIcsDate(value: string): Date {
|
function parseIcsDate(value: string): Date {
|
||||||
const match = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(value);
|
const match = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(value);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -131,3 +236,8 @@ function parseIcsDate(value: string): Date {
|
|||||||
? new Date(Date.UTC(year, month, day, hour, minute, second))
|
? new Date(Date.UTC(year, month, day, hour, minute, second))
|
||||||
: new Date(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;
|
due?: Date;
|
||||||
priority?: number;
|
priority?: number;
|
||||||
parentUid?: string;
|
parentUid?: string;
|
||||||
children?: Todo[];
|
|
||||||
|
|
||||||
raw: Record<string, string>;
|
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[] {
|
export function buildTodoTree(todos: Todo[]): RuntimeTodo[] {
|
||||||
const nodes = new Map<string, Todo>();
|
const nodes = new Map<string, RuntimeTodo>();
|
||||||
for (const todo of todos) {
|
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()) {
|
for (const node of nodes.values()) {
|
||||||
const parent = node.parentUid ? nodes.get(node.parentUid) : undefined;
|
const parent = node.parentUid ? nodes.get(node.parentUid) : undefined;
|
||||||
if (parent && parent.uid !== node.uid && !isDescendant(node, parent.uid)) {
|
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,
|
// Guards against a malformed RELATED-TO chain on the server forming a cycle,
|
||||||
// which would otherwise make the tree recurse forever when rendered.
|
// 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;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 react from '@vitejs/plugin-react'
|
||||||
import { defineConfig, loadEnv, type ProxyOptions } from 'vite'
|
import { defineConfig, loadEnv, type ProxyOptions } from 'vite'
|
||||||
|
|
||||||
@@ -23,6 +25,13 @@ export default defineConfig(({ mode }) => {
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/caldav/, basePath),
|
rewrite: (path) => path.replace(/^\/caldav/, basePath),
|
||||||
headers,
|
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 }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user