wip
This commit is contained in:
parent
c2fbeef6e5
commit
04980a1869
60
bun.lock
60
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=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
115
packages/http/src/routes/todos.tsx
Normal file
115
packages/http/src/routes/todos.tsx
Normal file
|
|
@ -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<typeof loader>) => {
|
||||
const { data, loading, error } = useAction()
|
||||
|
||||
return (
|
||||
<div class="bg-blue-50 min-h-screen pb-8">
|
||||
<h1 class="p-4 text-2xl bg-blue-100 text-blue-800">Reminders</h1>
|
||||
<main class="p-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-medium text-blue-800 mb-4">Current Reminders</h2>
|
||||
<Reminders reminders={props.reminders} />
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<CreateReminder loading={loading} success={data?.success} error={error ?? data?.error} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Reminders = ({ reminders }: { reminders: Reminder[] }) => {
|
||||
return (
|
||||
<>
|
||||
{reminders.length === 0 ? (
|
||||
<p class="text-gray-600">No reminders found.</p>
|
||||
) : (
|
||||
<ul class="bg-white rounded-lg shadow overflow-hidden divide-y divide-gray-200">
|
||||
{reminders.map((reminder) => (
|
||||
<li key={reminder.id} class="p-4 hover:bg-gray-50">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="font-medium">{reminder.title}</div>
|
||||
<div class="text-sm text-gray-500 flex justify-between mt-1 items-center gap-2">
|
||||
<span>Due: {new Date(reminder.dueDate).toLocaleString()}</span>
|
||||
{reminder.assignee && (
|
||||
<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded ml-2">
|
||||
{reminder.assignee}
|
||||
</span>
|
||||
)}
|
||||
<div class="text-sm text-gray-500 flex p-2">{reminder.status}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Form method="POST" class="inline-block">
|
||||
<input type="hidden" name="_action" value="delete" />
|
||||
<input type="hidden" name="id" value={reminder.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="text-red-600 hover:text-red-800 text-sm font-medium"
|
||||
title="Delete Reminder"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export type Conversation = { message: string; role: "user" | "assistant" }
|
|||
type Keys = {
|
||||
threads: Record<string, string> // threadId: previousResponseId
|
||||
reminders: Reminder[]
|
||||
todos: Record<string, string> // todoId: todoText
|
||||
}
|
||||
const version = 10
|
||||
|
||||
|
|
|
|||
34
packages/todo/.gitignore
vendored
Normal file
34
packages/todo/.gitignore
vendored
Normal file
|
|
@ -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
|
||||
15
packages/todo/README.md
Normal file
15
packages/todo/README.md
Normal file
|
|
@ -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.
|
||||
1
packages/todo/index.ts
Normal file
1
packages/todo/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
console.log("Hello via Bun!");
|
||||
27
packages/todo/package.json
Normal file
27
packages/todo/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
37
packages/todo/src/autoTodoOnNewline.ts
Normal file
37
packages/todo/src/autoTodoOnNewline.ts
Normal file
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
3
packages/todo/src/main.ts
Normal file
3
packages/todo/src/main.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export {TodoEditor } from "@/todoEditor"
|
||||
export {parseTodoLine, parseTodoList} from "@/todo"
|
||||
export type {Todo} from "@/todo"
|
||||
171
packages/todo/src/todo.test.ts
Normal file
171
packages/todo/src/todo.test.ts
Normal file
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
142
packages/todo/src/todo.ts
Normal file
142
packages/todo/src/todo.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
64
packages/todo/src/todoDecorations.ts
Normal file
64
packages/todo/src/todoDecorations.ts
Normal file
|
|
@ -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<void>()
|
||||
|
||||
export const todoDecorations = (filterRef: RefObject<string>) => {
|
||||
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<Decoration>()
|
||||
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 }
|
||||
)
|
||||
}
|
||||
101
packages/todo/src/todoEditor.tsx
Normal file
101
packages/todo/src/todoEditor.tsx
Normal file
|
|
@ -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<HTMLDivElement>(null)
|
||||
const editorRef = useRef<EditorView>(null)
|
||||
const [filter, setFilter] = useState<string>("")
|
||||
const filterRef = useRef(filter)
|
||||
const filterElRef = useRef<HTMLInputElement>(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 (
|
||||
<div class="h-dvh w-dvw flex flex-col">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by tag"
|
||||
value={filter}
|
||||
ref={filterElRef}
|
||||
onKeyUp={filterInput}
|
||||
onFocus={(e) => (e.target as HTMLInputElement).select()}
|
||||
class="p-2 border-b"
|
||||
/>
|
||||
<div ref={editorContainer} class="grow" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 <tab>
|
||||
- [x] Complete a task by pressing <opt+k>
|
||||
|
||||
# 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()
|
||||
51
packages/todo/src/todoKeymap.ts
Normal file
51
packages/todo/src/todoKeymap.ts
Normal file
|
|
@ -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<HTMLInputElement>) => {
|
||||
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,
|
||||
},
|
||||
])
|
||||
}
|
||||
34
packages/todo/tsconfig.json
Normal file
34
packages/todo/tsconfig.json
Normal file
|
|
@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user