diff --git a/bun.lock b/bun.lock index e2dccdd..87c72dc 100644 --- a/bun.lock +++ b/bun.lock @@ -62,7 +62,7 @@ }, }, "packages/spike": { - "name": "@workshop/spike2", + "name": "@workshop/spike", "dependencies": { "@openai/agents": "^0.0.8", "discord.js": "^14.19.3", @@ -77,8 +77,36 @@ "typescript": "^5", }, }, + "packages/text-do": { + "name": "text-do", + "dependencies": { + "@codemirror/commands": "^6.8.1", + "@codemirror/language": "^6.11.1", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.37.2", + "@lezer/generator": "^1.7.3", + "@lezer/highlight": "^1.2.1", + "@lezer/lr": "^1.4.2", + "hono": "^4.8.0", + "luxon": "^3.6.1", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, }, "packages": { + "@codemirror/commands": ["@codemirror/commands@6.8.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw=="], + + "@codemirror/language": ["@codemirror/language@6.11.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.1.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-5kS1U7emOGV84vxC+ruBty5sUgcD0te6dyupyRVG2zaSjhTDM73LhVKUtVwiqSe6QwmEoA4SCiU8AKPFyumAWQ=="], + + "@codemirror/state": ["@codemirror/state@6.5.2", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA=="], + + "@codemirror/view": ["@codemirror/view@6.37.2", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-XD3LdgQpxQs5jhOOZ2HRVT+Rj59O4Suc7g2ULvZ+Yi8eCkickrkZ5JFuoDhs2ST1mNI5zSsNYgR3NGa4OUrbnw=="], + "@discordjs/builders": ["@discordjs/builders@1.11.2", "", { "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.1", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-F1WTABdd8/R9D1icJzajC4IuLyyS8f3rTOz66JsSI3pKvpCAtsMBweu8cyNYsIyvcrKAVn9EPK+Psoymq+XC0A=="], "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], @@ -91,6 +119,16 @@ "@discordjs/ws": ["@discordjs/ws@1.2.2", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.0", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-dyfq7yn0wO0IYeYOs3z79I6/HumhmKISzFL0Z+007zQJMtAFGtt3AEoq1nuLXtcunUE5YYYQqgKvybXukAK8/w=="], + "@lezer/common": ["@lezer/common@1.2.3", "", {}, "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA=="], + + "@lezer/generator": ["@lezer/generator@1.7.3", "", { "dependencies": { "@lezer/common": "^1.1.0", "@lezer/lr": "^1.3.0" }, "bin": { "lezer-generator": "src/lezer-generator.cjs" } }, "sha512-vAI2O1tPF8QMMgp+bdUeeJCneJNkOZvqsrtyb4ohnFVFdboSqPwBEacnt0HH4E+5h+qsIwTHUSAhffU4hzKl1A=="], + + "@lezer/highlight": ["@lezer/highlight@1.2.1", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA=="], + + "@lezer/lr": ["@lezer/lr@1.4.2", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA=="], + + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.12.3", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-DyVYSOafBvk3/j1Oka4z5BWT8o4AFmoNyZY9pALOm7Lh3GZglR71Co4r4dEUoqDWdDazIZQHBe7J2Nwkg6gHgQ=="], "@openai/agents": ["@openai/agents@0.0.8", "", { "dependencies": { "@openai/agents-core": "0.0.8", "@openai/agents-openai": "0.0.8", "@openai/agents-realtime": "0.0.8", "debug": "^4.4.0", "openai": "^5.0.1" } }, "sha512-HAPP4QM47kWeWw70uxCzr5zjqHuDIvQ8Obx+98J66lcEeIZzMChHN60k5ew8DITScmzDVAVuwdzfAImSyq002w=="], @@ -131,7 +169,7 @@ "@workshop/shared": ["@workshop/shared@workspace:packages/shared"], - "@workshop/spike2": ["@workshop/spike2@workspace:packages/spike"], + "@workshop/spike": ["@workshop/spike@workspace:packages/spike"], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -157,6 +195,8 @@ "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], @@ -307,6 +347,10 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "style-mod": ["style-mod@4.1.2", "", {}, "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="], + + "text-do": ["text-do@workspace:packages/text-do"], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], @@ -327,6 +371,8 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -341,6 +387,16 @@ "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + "@workshop/spike/@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="], + "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + + "text-do/@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="], + + "text-do/hono": ["hono@4.8.2", "", {}, "sha512-hM+1RIn9PK1I6SiTNS6/y7O1mvg88awYLFEuEtoiMtRyT3SD2iu9pSFgbBXT3b1Ua4IwzvSTLvwO0SEhDxCi4w=="], + + "@workshop/spike/@types/bun/bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="], + + "text-do/@types/bun/bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="], } } diff --git a/packages/http/src/routes/index.tsx b/packages/http/src/routes/reminders.tsx similarity index 100% rename from packages/http/src/routes/index.tsx rename to packages/http/src/routes/reminders.tsx diff --git a/packages/http/src/routes/todos.tsx b/packages/http/src/routes/todos.tsx new file mode 100644 index 0000000..fa04354 --- /dev/null +++ b/packages/http/src/routes/todos.tsx @@ -0,0 +1,115 @@ +import KV from "@workshop/shared/kv" +import { Form, type Head, type LoaderProps, useAction } from "@workshop/nano-remix" +import { addReminder, deleteReminder, type Reminder } from "@workshop/shared/reminders" +import { CreateReminder } from "@/components/createReminder" + +export const head: Head = { + title: "Todos", + scripts: [{ src: "https://cdn.tailwindcss.com" }], +} + +export const loader = async (req: Request) => { + const todos = await KV.get("todos", {}) + return { todos } +} + +export const action = async (req: Request) => { + const formData = await req.formData() + const action = formData.get("_action") as string + + // Handle delete action + if (action === "delete") { + const id = formData.get("id") as string + if (!id) { + return { error: "Reminder ID is required" } + } + + try { + await deleteReminder(id) + return { success: true, message: "Reminder deleted successfully" } + } catch (error) { + return { error: `Failed to delete reminder: ${error}` } + } + } else if (action === "create") { + // Handle create action (default) + const title = formData.get("title") as string + const dueDate = formData.get("dueDate") as string + const assignee = (formData.get("assignee") as string) || undefined + + if (!title) { + return { error: "Title is required" } + } + + if (!dueDate) { + return { error: "Due date is required" } + } + + try { + const newReminder = await addReminder(title, dueDate, assignee as any) + return { success: true, newReminder } + } catch (error) { + return { error: `Failed to create reminder: ${error}` } + } + } +} + +export default (props: LoaderProps) => { + const { data, loading, error } = useAction() + + return ( +
+

Reminders

+
+
+

Current Reminders

+ +
+
+ +
+
+
+ ) +} + +const Reminders = ({ reminders }: { reminders: Reminder[] }) => { + return ( + <> + {reminders.length === 0 ? ( +

No reminders found.

+ ) : ( + + )} + + ) +} diff --git a/packages/shared/src/kv.ts b/packages/shared/src/kv.ts index beb88e7..5daae36 100644 --- a/packages/shared/src/kv.ts +++ b/packages/shared/src/kv.ts @@ -6,6 +6,7 @@ export type Conversation = { message: string; role: "user" | "assistant" } type Keys = { threads: Record // threadId: previousResponseId reminders: Reminder[] + todos: Record // todoId: todoText } const version = 10 diff --git a/packages/todo/.gitignore b/packages/todo/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/packages/todo/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/packages/todo/README.md b/packages/todo/README.md new file mode 100644 index 0000000..284e5cd --- /dev/null +++ b/packages/todo/README.md @@ -0,0 +1,15 @@ +# text-do + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.2.16. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/packages/todo/index.ts b/packages/todo/index.ts new file mode 100644 index 0000000..f67b2c6 --- /dev/null +++ b/packages/todo/index.ts @@ -0,0 +1 @@ +console.log("Hello via Bun!"); \ No newline at end of file diff --git a/packages/todo/package.json b/packages/todo/package.json new file mode 100644 index 0000000..e5bdd7d --- /dev/null +++ b/packages/todo/package.json @@ -0,0 +1,27 @@ +{ + "name": "todo", + "module": "index.ts", + "type": "module", + "private": true, + "dependencies": { + "@codemirror/commands": "^6.8.1", + "@codemirror/language": "^6.11.1", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.37.2", + "@lezer/generator": "^1.7.3", + "@lezer/highlight": "^1.2.1", + "@lezer/lr": "^1.4.2", + "hono": "^4.8.0", + "luxon": "^3.6.1", + }, + "prettier": { + "semi": false, + "printWidth": 110 + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} \ No newline at end of file diff --git a/packages/todo/src/autoTodoOnNewline.ts b/packages/todo/src/autoTodoOnNewline.ts new file mode 100644 index 0000000..e98e344 --- /dev/null +++ b/packages/todo/src/autoTodoOnNewline.ts @@ -0,0 +1,37 @@ +import { EditorView } from "@codemirror/view" + +export const autoTodoOnNewline = EditorView.updateListener.of((update) => { + if (update.state.selection.ranges.length > 1) return + + for (const tr of update.transactions) { + if (!tr.isUserEvent("input")) return + + tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + if (inserted.toString() !== "\n") return + + const prefix = "- [ ] " + // If previous line was an empty todo, remove the empty line instead of continuing + const insertPos = fromB + inserted.toString().length + const prevLine = update.state.doc.lineAt(insertPos - 1) + + // if the previous line is empty or just whitespace, just insert the newline + if (prevLine.text.trim() === "") { + return + } else if (prevLine.text.trim() === prefix.trim()) { + const start = prevLine.from + const end = insertPos + update.view.dispatch({ + changes: { from: start, to: end, insert: "" }, + selection: { anchor: start }, + userEvent: "delete", + }) + } else { + update.view.dispatch({ + changes: { from: insertPos, to: insertPos, insert: prefix }, + selection: { anchor: insertPos + prefix.length }, + userEvent: "todo.newline", + }) + } + }) + } +}) diff --git a/packages/todo/src/main.ts b/packages/todo/src/main.ts new file mode 100644 index 0000000..7ed3a77 --- /dev/null +++ b/packages/todo/src/main.ts @@ -0,0 +1,3 @@ +export {TodoEditor } from "@/todoEditor" +export {parseTodoLine, parseTodoList} from "@/todo" +export type {Todo} from "@/todo" \ No newline at end of file diff --git a/packages/todo/src/todo.test.ts b/packages/todo/src/todo.test.ts new file mode 100644 index 0000000..6446f5d --- /dev/null +++ b/packages/todo/src/todo.test.ts @@ -0,0 +1,171 @@ +import { parseTodoList, parseTodoLine, Todo, TodoMeta } from "./todo" +import { test, expect } from "bun:test" + +test("parse things", () => { + check("- [ ] some words and that is it", { + done: false, + text: "some words and that is it", + tags: [], + children: [], + header: undefined, + }) + + check("- [x] some words", { + done: true, + text: "some words", + tags: [], + children: [], + header: undefined, + }) + + check("- [ ] some words -and - [ ] not another task", { + done: false, + text: "some words -and - [ ] not another task", + tags: [], + children: [], + header: undefined, + }) + + check("- [ ] a task with a #tag", { + done: false, + text: "a task with a #tag", + tags: ["tag"], + children: [], + header: undefined, + }) + + check("- [ ] a #tag in a task", { + done: false, + text: "a #tag in a task", + tags: ["tag"], + children: [], + header: undefined, + }) + + check("- [ ] a #tag and #another.", { + done: false, + text: "a #tag and #another.", + tags: ["tag", "another"], + children: [], + header: undefined, + }) + + check("- [ ] a word with a po#nd sign", { + done: false, + text: "a word with a po#nd sign", + tags: [], + children: [], + header: undefined, + }) + + check("- [ ] a date @1/2/25!", { + done: false, + text: "a date @1/2/25!", + tags: [], + dueDate: "2025-01-02", + children: [], + header: undefined, + }) + + check("- [ ] a date @2-2-25!", { + done: false, + text: "a date @2-2-25!", + tags: [], + dueDate: "2025-02-02", + children: [], + header: undefined, + }) + + check("- [ ] a @1/2/25 date and #tag", { + done: false, + text: "a @1/2/25 date and #tag", + tags: ["tag"], + dueDate: "2025-01-02", + children: [], + header: undefined, + }) + + check(" ", []) + + // Invalid formats + check("- [x status error", []) + check("- [. ] status error", []) + check("just text", []) + + // A full todo list + check( + ` +# Today +- [ ] a task with a #tag +- [x] a #tag and date @2/2/25 in a task + +# Tomorrow +- [ ] another task + - [ ] a subtask + - [x] completed + +- [ ] a task with a @1/2/25 date + +`, + [ + { + done: false, + text: "a task with a #tag", + tags: ["tag"], + dueDate: undefined, + children: [], + header: "Today", + }, + { + done: true, + text: "a #tag and date @2/2/25 in a task", + tags: ["tag"], + dueDate: "2025-02-02", + children: [], + header: "Today", + }, + { + done: false, + text: "another task", + tags: [], + dueDate: undefined, + children: [ + { done: false, text: "a subtask", tags: [], dueDate: undefined, children: [], header: "Tomorrow" }, + { done: true, text: "completed", tags: [], dueDate: undefined, children: [], header: "Tomorrow" }, + ], + header: "Tomorrow", + }, + { + done: false, + text: "a task with a @1/2/25 date", + tags: [], + dueDate: "2025-01-02", + children: [], + header: "Tomorrow", + }, + ] + ) +}) + +test("parse metadata positions", () => { + const line = "- [ ] do #tag @1/2/25" + const parsed = parseTodoLine(line) + expect(parsed, line).toBeDefined() + const { meta } = parsed as { todo: Todo; meta: TodoMeta } + + // '#tag' starts at index 9, ends at 9 + 4 + expect(meta.tags).toEqual([{ start: 7, end: 12 }]) + + // '+1/2/25' starts at index 14, ends at 14 + 6 + 1 = 21 + expect(meta.dates).toEqual([{ start: 12, end: 18 }]) +}) + +const check = (input: string, expectation: Todo | Todo[]) => { + const result = parseTodoList(input) + + if (expectation === undefined) { + expect(result, input).toBeUndefined() + } else { + expect(result, input).toEqual(Array.isArray(expectation) ? expectation : [expectation]) + } +} diff --git a/packages/todo/src/todo.ts b/packages/todo/src/todo.ts new file mode 100644 index 0000000..e819581 --- /dev/null +++ b/packages/todo/src/todo.ts @@ -0,0 +1,142 @@ +import { DateTime } from "luxon" + +export type Todo = { + done: boolean + text: string + tags: string[] + dueDate?: string + children: Todo[] + header?: string + nested?: boolean +} + +export type TodoMeta = { + type: "tag" | "date" + start: number + end: number +}[] + +export const parseTodoList = (text: string, zone = "America/Los_Angeles") => { + const todos: Todo[] = [] + const lines = text.split("\n") + let currentParent: Todo | undefined + let currentHeader + for (const line of lines) { + const headerMatch = line.match(/^\s*#+\s*(.*)/) + if (headerMatch) { + currentParent = undefined + currentHeader = headerMatch[1].trim() + continue + } else if (line.trim() === "") { + continue + } + + const parsed = parseTodoLine(line, currentHeader, currentParent, zone) + if (parsed) { + const { todo } = parsed + if (todo && !todo.nested) { + todos.push(todo) + currentParent = todo + } + } else { + // console.warn(`❌ Skipping invalid todo line "${line}"`) + } + } + return todos +} + +export const todoToText = (todo: Todo): string => { + let indent = "" + if (todo.nested) { + indent += " " // Indent for nested todos + } + const text = `${indent}- [${todo.done ? "x" : " "}] ${todo.text}` + return text +} + +const invalidLine = { todo: undefined, meta: undefined } as const +export const parseTodoLine = ( + line: string, + header?: string, + parent?: Todo, + zone = "America/Los_Angeles" +): { todo: Todo; meta: TodoMeta } | typeof invalidLine => { + const meta: TodoMeta = [] + let offset = 0 + + const todo: Todo = { + done: false, + text: "", + tags: [], + dueDate: undefined, + children: [], + header, + nested: isSubtodo(line), + } + + // Is it a nested task? + if (todo.nested) { + if (!parent) { + // console.warn(`❌ Ignoring Nested task without a parent "${line}"`) + return invalidLine + } + parent.children.push(todo) + } + + offset += line.search(/\S/) + line = line.trimStart() + + // Check for status + const match = line.match(/- \[\s*(\w?)\s*\]\s+/i) + if (!match) return invalidLine // Invalid format, no status found + todo.done = Boolean(match[1]) + offset += match[0].length + line = line.slice(match[0].length).trimStart() + + // Save text + todo.text = line + + // Extract tags + todo.text.matchAll(/(?:^|\s)#(\w+)/g).forEach((match) => { + const start = match.index! + offset + const end = start + match[0].length + meta.push({ type: "tag", start, end }) + todo.tags.push(match[1]) + }) + + // Extract due date in Month/Day/Year + const dateMatches = todo.text.matchAll(/(?:^|\s)@([\d\/\-]{6,})/g) + + for (const match of dateMatches) { + let [month, day, year] = match[1].split(/[\/-]/) + if (!month || !day || !year) continue + + if (year.length == 2) year = `20${year}` // Assume 21st century for two-digit years + const dateTime = DateTime.fromObject( + { + year: parseInt(year, 10), + month: parseInt(month, 10), + day: parseInt(day, 10), + }, + { zone } + ) + + if (dateTime.isValid) { + const start = match.index + offset + const end = start + match[0].length + + meta.push({ type: "date", start, end }) + todo.dueDate = dateTime.toISODate() + break + } else { + console.warn(`🕰️ Invalid date format "${match[1]}"`) + } + } + + meta.sort((a, b) => a.start - b.start) // CodeMiror expects metadata to be sorted by start position + return { todo, meta } +} + +const isSubtodo = (line: string) => { + return /^\s+/.test(line) +} diff --git a/packages/todo/src/todoDecorations.ts b/packages/todo/src/todoDecorations.ts new file mode 100644 index 0000000..ff7014d --- /dev/null +++ b/packages/todo/src/todoDecorations.ts @@ -0,0 +1,64 @@ +import { parseTodoLine, Todo } from "@/todo/todo" +import { RangeSetBuilder, StateEffect } from "@codemirror/state" +import { EditorView, Decoration, ViewPlugin, ViewUpdate } from "@codemirror/view" +import { RefObject } from "hono/jsx" + +// Effect to trigger a decoration refresh on filter change +export const refreshFilterEffect = StateEffect.define() + +export const todoDecorations = (filterRef: RefObject) => { + return ViewPlugin.fromClass( + class { + decorations: any + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view) + } + + update(update: ViewUpdate) { + if ( + update.docChanged || + update.viewportChanged || + update.transactions.some((tr) => tr.effects.some((e) => e.is(refreshFilterEffect))) + ) { + this.decorations = this.buildDecorations(update.view) + } + } + + buildDecorations(view: EditorView) { + const builder = new RangeSetBuilder() + const { doc } = view.state + const dummyParent: Todo = { done: false, text: "", tags: [], children: [] } + for (const { from, to } of view.visibleRanges) { + let pos = from + while (pos <= to) { + const line = doc.lineAt(pos) + const text = line.text + const { todo, meta } = parseTodoLine(text, "", dummyParent) + + if (todo) { + if (filterRef.current && !todo.tags.find((t) => t.startsWith(filterRef.current!))) { + builder.add(line.from, line.from, Decoration.line({ attributes: { style: "display: none" } })) + } + + if (todo.done) { + builder.add(line.from, line.to, Decoration.mark({ class: "todo-completed" })) + } + + for (const { type, start, end } of meta) { + builder.add(line.from + start, line.from + end, Decoration.mark({ class: `todo-${type}` })) + } + } else if (/^\s*#+\s/.test(text)) { + builder.add(line.from, line.to, Decoration.mark({ class: "todo-header" })) + } else if (text.trim() !== "") { + builder.add(line.from, line.to, Decoration.mark({ class: "todo-invalid" })) + } + + pos = line.to + 1 + } + } + return builder.finish() + } + }, + { decorations: (v) => v.decorations } + ) +} diff --git a/packages/todo/src/todoEditor.tsx b/packages/todo/src/todoEditor.tsx new file mode 100644 index 0000000..8b72541 --- /dev/null +++ b/packages/todo/src/todoEditor.tsx @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useRef, useState } from "hono/jsx" +import { defaultKeymap, history, historyKeymap } from "@codemirror/commands" +import { EditorState } from "@codemirror/state" +import { EditorView, lineNumbers, keymap } from "@codemirror/view" +import { keymap as viewKeymap } from "@codemirror/view" // ensure keymap is imported from view if not already +import { foldGutter, foldKeymap } from "@codemirror/language" +import { refreshFilterEffect, todoDecorations } from "@/todo/todoDecorations" +import { autoTodoOnNewline } from "@/todo/autoTodoOnNewline" +import { todoKeymap } from "@/todo/todoKeymap" +import { DateTime } from "luxon" + +import "./index.css" + +export const TodoEditor = () => { + const editorContainer = useRef(null) + const editorRef = useRef(null) + const [filter, setFilter] = useState("") + const filterRef = useRef(filter) + const filterElRef = useRef(null) + + useEffect(() => { + filterRef.current = filter + editorRef.current?.dispatch({ effects: refreshFilterEffect.of() }) // trigger update to decorations when filter changes + }, [filter]) + + useEffect(() => { + if (editorRef.current) editorRef.current.destroy() + if (!editorContainer.current) return + + const changeListener = EditorView.updateListener.of((update) => { + localStorage.setItem("todo-editor-content", update.state.doc.toString()) + }) + + const savedDoc = localStorage.getItem("todo-editor-content") + const docText = savedDoc ?? defaultDoc + const state = EditorState.create({ + doc: docText, + extensions: [ + foldGutter(), + lineNumbers(), + history(), + todoDecorations(filterRef), + changeListener, + autoTodoOnNewline, + todoKeymap(filterElRef), + keymap.of(historyKeymap), + keymap.of(defaultKeymap), + viewKeymap.of(foldKeymap), + ], + }) + + const view = new EditorView({ state, parent: editorContainer.current }) + view.focus() + editorRef.current = view + + return () => view.destroy() + }, []) + + const filterInput = useCallback((e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === "Escape") { + if (!editorRef.current) return + editorRef.current?.focus() + e.preventDefault() + } else { + setFilter((e.target as HTMLInputElement).value) + } + }, []) + + return ( +
+ (e.target as HTMLInputElement).select()} + class="p-2 border-b" + /> +
+
+ ) +} + +export default App + +const defaultDoc = ` +# Today (Group tasks by when they are due) + +- [ ] Sample task with a due date @${DateTime.local().toFormat("yyyy/MM/dd")} +- [ ] You can use a #tag to filter tasks + - [ ] A sub task! Create nested tasks by indenting with +- [x] Complete a task by pressing + +# This week +- [ ] Another task with a due date @${DateTime.local().plus({ days: 3 }).toFormat("yyyy/MM/dd")} + +# Later +- [ ] I use later as a junk drawer for tasks I don't want to forget + +`.trim() diff --git a/packages/todo/src/todoKeymap.ts b/packages/todo/src/todoKeymap.ts new file mode 100644 index 0000000..8c8db04 --- /dev/null +++ b/packages/todo/src/todoKeymap.ts @@ -0,0 +1,51 @@ +import { indentMore, indentLess } from "@codemirror/commands" +import { EditorView, keymap } from "@codemirror/view" +import { parseTodoLine, todoToText } from "./todo" +import { RefObject } from "hono/jsx" + +export const todoKeymap = (filterElRef: RefObject) => { + return keymap.of([ + { + key: "alt-l", // Focus the filter input + preventDefault: true, + run: (_view: EditorView) => { + filterElRef.current?.focus() + return true + }, + }, + { + key: "alt-k", // toggle done state of the todo item + preventDefault: true, + run: (view: EditorView) => { + const { state } = view + const { head } = state.selection.main + const line = state.doc.lineAt(head) + + const { todo, meta } = parseTodoLine(line.text) + if (!todo) return false + todo.done = !todo.done + const updatedLine = todoToText(todo) + + const from = line.from + const to = line.to + const offset = head - from + view.dispatch({ + changes: { from, to, insert: updatedLine }, + selection: { anchor: from + offset }, + userEvent: "input", + }) + return true + }, + }, + { + key: "Tab", + preventDefault: true, + run: indentMore, + }, + { + key: "Shift-Tab", + preventDefault: true, + run: indentLess, + }, + ]) +} diff --git a/packages/todo/tsconfig.json b/packages/todo/tsconfig.json new file mode 100644 index 0000000..dd311f4 --- /dev/null +++ b/packages/todo/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +}