experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(todo): cmd-panel `todo` and `done` commands

New `features/todo/` lazy-loaded extension wires two cmd-panel
commands:

- `todo <text> [#tag …]` — creates a text item, auto-tags with
`todo`, plus any inline `#tag` words. Inline tags can be mixed
through the body — anything starting with `#` is a tag, the rest
is the body.
- `done <match>` — finds the first `#todo` item whose title or
content contains `<match>` (case-insensitive), swaps the `todo`
tag for `done`, records a `completed` item event, and reports
how many other matches were skipped if any.

Manifest declares both commands so they appear in cmd-panel
suggestions; handlers run in the lazy `peek://todo/background.html`
tile and use the existing `tagItem` / `getItemsByTag` /
`untagItem` datastore APIs (same surface used by features/tags).

Out of scope (filed for follow-up): the spec also called for a
tab/down-arrow autocomplete that searches existing `#todo` items
and opens one in the editor — that needs deeper cmd-panel
suggestion-source plumbing and is best done together with related
"open in editor" cmd flows.

+295
+29
features/todo/background.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 + <title>Todo Extension</title> 7 + </head> 8 + <body> 9 + <script type="module"> 10 + import extension from './background.js'; 11 + 12 + const api = window.app; 13 + const extId = extension.id; 14 + 15 + console.log(`[ext:${extId}] initializing v2 tile`); 16 + await api.initialize(); 17 + 18 + if (extension.init) { 19 + await extension.init(); 20 + } 21 + 22 + api.onShutdown(() => { 23 + if (extension.uninit) { 24 + extension.uninit(); 25 + } 26 + }); 27 + </script> 28 + </body> 29 + </html>
+199
features/todo/background.js
··· 1 + /** 2 + * Todo Extension Background Script 3 + * 4 + * Two cmd-panel commands: 5 + * - `todo {text} [#tag …]` — creates a text item, auto-tags with `todo` 6 + * plus any inline `#tag` words. 7 + * - `done {match}` — finds the first `todo`-tagged item whose 8 + * title/content contains `match` (case-insensitive), swaps the `todo` 9 + * tag for `done`, records a `done` item event. 10 + * 11 + * Lazy-loaded: tile boots on first `cmd:execute:todo` / `cmd:execute:done`. 12 + * 13 + * Runs in isolated extension process (peek://todo/background.html). 14 + */ 15 + 16 + import { id, labels, schemas, storageKeys, defaults } from './config.js'; 17 + 18 + const api = window.app; 19 + const debug = api.debug; 20 + 21 + const TODO_TAG = 'todo'; 22 + const DONE_TAG = 'done'; 23 + 24 + // ===== Parsing ===== 25 + 26 + /** 27 + * Split a `todo` command argument string into the body text and any 28 + * trailing `#tag` words. Inline tags can appear anywhere — the body is 29 + * non-tag tokens concatenated by single spaces. 30 + */ 31 + const parseTodoArgs = (raw) => { 32 + const text = (raw || '').trim(); 33 + if (!text) return { body: '', extraTags: [] }; 34 + const extraTags = []; 35 + const bodyParts = []; 36 + for (const tok of text.split(/\s+/)) { 37 + if (tok.startsWith('#') && tok.length > 1) { 38 + extraTags.push(tok.slice(1)); 39 + } else { 40 + bodyParts.push(tok); 41 + } 42 + } 43 + return { body: bodyParts.join(' ').trim(), extraTags }; 44 + }; 45 + 46 + // ===== Tag helpers (mirrors features/tags/background.js patterns) ===== 47 + 48 + const ensureTag = async (name) => { 49 + const r = await api.datastore.getOrCreateTag(name); 50 + if (!r.success) { 51 + console.error('[ext:todo] getOrCreateTag failed:', name, r.error); 52 + return null; 53 + } 54 + return r.data.tag; 55 + }; 56 + 57 + const tagWithName = async (itemId, name) => { 58 + const tag = await ensureTag(name); 59 + if (!tag) return false; 60 + const link = await api.datastore.tagItem(itemId, tag.id); 61 + if (!link.success) { 62 + console.error('[ext:todo] tagItem failed:', name, link.error); 63 + return false; 64 + } 65 + return true; 66 + }; 67 + 68 + const untagWithName = async (itemId, name) => { 69 + // Find the tag on this item by name (case-insensitive). 70 + const tagsResult = await api.datastore.getItemTags(itemId); 71 + if (!tagsResult.success) return false; 72 + const tag = tagsResult.data.find(t => t.name.toLowerCase() === name.toLowerCase()); 73 + if (!tag) return false; 74 + const r = await api.datastore.untagItem(itemId, tag.id); 75 + return r.success; 76 + }; 77 + 78 + // ===== Command handlers ===== 79 + 80 + const handleTodo = async (rawArgs) => { 81 + const { body, extraTags } = parseTodoArgs(rawArgs); 82 + if (!body) { 83 + return { output: 'Usage: todo <text> [#tag ...]', mimeType: 'text/plain' }; 84 + } 85 + 86 + const result = await api.datastore.addItem('text', { 87 + title: body, 88 + content: body 89 + }); 90 + if (!result.success) { 91 + console.error('[ext:todo] addItem failed:', result.error); 92 + return { output: 'Failed to create todo', mimeType: 'text/plain' }; 93 + } 94 + const itemId = result.data.id; 95 + 96 + // Always tag #todo, then any extras the user named inline. 97 + await tagWithName(itemId, TODO_TAG); 98 + for (const t of extraTags) { 99 + await tagWithName(itemId, t); 100 + } 101 + 102 + api.pubsub.publish('todo:created', { id: itemId, title: body, extraTags }); 103 + 104 + const tagSummary = extraTags.length > 0 105 + ? ` (#${TODO_TAG}, #${extraTags.join(', #')})` 106 + : ` (#${TODO_TAG})`; 107 + return { 108 + output: `Added: ${body}${tagSummary}`, 109 + mimeType: 'text/plain' 110 + }; 111 + }; 112 + 113 + const handleDone = async (rawArgs) => { 114 + const match = (rawArgs || '').trim(); 115 + if (!match) { 116 + return { output: 'Usage: done <text-substring-of-todo>', mimeType: 'text/plain' }; 117 + } 118 + 119 + // Resolve the #todo tag so we can scope the search. 120 + const todoTag = await ensureTag(TODO_TAG); 121 + if (!todoTag) { 122 + return { output: 'No #todo tag exists yet — nothing to mark done', mimeType: 'text/plain' }; 123 + } 124 + 125 + const itemsResult = await api.datastore.getItemsByTag(todoTag.id); 126 + if (!itemsResult.success) { 127 + console.error('[ext:todo] getItemsByTag failed:', itemsResult.error); 128 + return { output: 'Failed to query todos', mimeType: 'text/plain' }; 129 + } 130 + 131 + const needle = match.toLowerCase(); 132 + const matches = itemsResult.data.filter(it => { 133 + const t = (it.title || '').toLowerCase(); 134 + const c = (it.content || '').toLowerCase(); 135 + return t.includes(needle) || c.includes(needle); 136 + }); 137 + 138 + if (matches.length === 0) { 139 + return { output: `No #todo item matches "${match}"`, mimeType: 'text/plain' }; 140 + } 141 + 142 + const target = matches[0]; 143 + await untagWithName(target.id, TODO_TAG); 144 + await tagWithName(target.id, DONE_TAG); 145 + 146 + // Record an event so audit trails / activity views see the transition. 147 + try { 148 + await api.datastore.recordItemEvent?.(target.id, 'completed', match); 149 + } catch { 150 + // Older datastore surfaces don't expose recordItemEvent — ignore. 151 + } 152 + 153 + api.pubsub.publish('todo:done', { id: target.id, title: target.title }); 154 + 155 + const overflow = matches.length > 1 156 + ? ` (${matches.length - 1} other match${matches.length === 2 ? '' : 'es'} skipped)` 157 + : ''; 158 + return { 159 + output: `Done: ${target.title || target.content || target.id}${overflow}`, 160 + mimeType: 'text/plain' 161 + }; 162 + }; 163 + 164 + // ===== Registration ===== 165 + 166 + const init = async () => { 167 + debug && console.log('[ext:todo] init'); 168 + 169 + api.pubsub.subscribe('cmd:execute:todo', async (msg) => { 170 + const result = await handleTodo(msg.search?.trim() || msg.data?.text || ''); 171 + if (msg.expectResult && msg.resultTopic) { 172 + api.pubsub.publish(msg.resultTopic, result || { success: true }); 173 + } 174 + }); 175 + 176 + api.pubsub.subscribe('cmd:execute:done', async (msg) => { 177 + const result = await handleDone(msg.search?.trim() || msg.data?.text || ''); 178 + if (msg.expectResult && msg.resultTopic) { 179 + api.pubsub.publish(msg.resultTopic, result || { success: true }); 180 + } 181 + }); 182 + 183 + // Manifest declares both commands; no need to publish cmd:register here. 184 + debug && console.log('[ext:todo] cmd:execute handlers wired (todo, done)'); 185 + }; 186 + 187 + const uninit = () => { 188 + debug && console.log('[ext:todo] uninit'); 189 + }; 190 + 191 + export default { 192 + defaults, 193 + id, 194 + init, 195 + uninit, 196 + labels, 197 + schemas, 198 + storageKeys 199 + };
+11
features/todo/config.js
··· 1 + const id = 'todo'; 2 + 3 + const labels = { 4 + name: 'Todo Commands', 5 + }; 6 + 7 + const schemas = {}; 8 + const storageKeys = {}; 9 + const defaults = { prefs: {} }; 10 + 11 + export { id, labels, schemas, storageKeys, defaults };
+56
features/todo/manifest.json
··· 1 + { 2 + "manifestVersion": 3, 3 + "id": "todo", 4 + "shortname": "todo", 5 + "name": "Todo Commands", 6 + "description": "Quick `todo` and `done` cmd-panel commands", 7 + "version": "1.0.0", 8 + "builtin": true, 9 + "tiles": [ 10 + { 11 + "id": "background", 12 + "url": "background.html", 13 + "lazy": true 14 + } 15 + ], 16 + "capabilities": { 17 + "pubsub": { 18 + "scopes": ["global", "system"], 19 + "topics": [ 20 + "todo:*", 21 + "cmd:execute:todo", 22 + "cmd:execute:done", 23 + "cmd:register", 24 + "cmd:unregister", 25 + "ext:ready", 26 + "app:shutdown", 27 + "ext:todo:shutdown", 28 + "tag:item-added", 29 + "tag:item-removed", 30 + "item:created" 31 + ] 32 + }, 33 + "datastore": { 34 + "tables": ["items", "tags", "item_tags"] 35 + }, 36 + "commands": true 37 + }, 38 + "commands": [ 39 + { 40 + "name": "todo", 41 + "description": "Create a todo item (auto-tagged #todo). Inline #tags add extra tags.", 42 + "action": { "type": "execute" }, 43 + "params": [ 44 + { "name": "text", "type": "string", "required": true, "description": "What needs doing. Trailing #-prefixed words become extra tags." } 45 + ] 46 + }, 47 + { 48 + "name": "done", 49 + "description": "Mark a todo done. Matches the first #todo item whose title or content contains the search text.", 50 + "action": { "type": "execute" }, 51 + "params": [ 52 + { "name": "match", "type": "string", "required": true, "description": "Substring of the todo's title or content (case-insensitive)." } 53 + ] 54 + } 55 + ] 56 + }