mulmoclaude 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (408) hide show
  1. package/README.md +44 -0
  2. package/bin/mulmoclaude.js +202 -0
  3. package/bin/prepare-dist.js +93 -0
  4. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +1 -0
  5. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +5 -0
  6. package/client/assets/index-D8rhwXLq.js +4906 -0
  7. package/client/assets/index-KNLBjwuh.css +1 -0
  8. package/client/assets/index.es-D4YyL_Dg-BfRHLTZV.js +5 -0
  9. package/client/assets/material-icons-Dr0goTwe.woff +0 -0
  10. package/client/assets/material-icons-kAwBdRge.woff2 +0 -0
  11. package/client/assets/material-icons-outlined-BpWbwl2n.woff +0 -0
  12. package/client/assets/material-icons-outlined-DZhiGvEA.woff2 +0 -0
  13. package/client/assets/material-icons-round-BDlwx-sv.woff +0 -0
  14. package/client/assets/material-icons-round-DrirKXBx.woff2 +0 -0
  15. package/client/assets/material-icons-sharp-CH1KkVu7.woff +0 -0
  16. package/client/assets/material-icons-sharp-gidztirS.woff2 +0 -0
  17. package/client/assets/material-icons-two-tone-B7wz7mED.woff +0 -0
  18. package/client/assets/material-icons-two-tone-DuNIpaEj.woff2 +0 -0
  19. package/client/assets/mulmo_bw-ERmkSv0a.png +0 -0
  20. package/client/assets/purify.es-Fx1Nqyry-PeS5RUhs.js +2 -0
  21. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +1 -0
  22. package/client/index.html +28 -0
  23. package/package.json +66 -0
  24. package/server/agent/attachmentConverter.ts +270 -0
  25. package/server/agent/config.ts +414 -0
  26. package/server/agent/index.ts +260 -0
  27. package/server/agent/mcp-server.ts +412 -0
  28. package/server/agent/mcp-tools/index.ts +63 -0
  29. package/server/agent/mcp-tools/x.ts +188 -0
  30. package/server/agent/plugin-names.ts +75 -0
  31. package/server/agent/prompt.ts +349 -0
  32. package/server/agent/resumeFailover.ts +129 -0
  33. package/server/agent/sandboxMounts.ts +329 -0
  34. package/server/agent/stream.ts +194 -0
  35. package/server/api/auth/bearerAuth.ts +61 -0
  36. package/server/api/auth/token.ts +98 -0
  37. package/server/api/csrfGuard.ts +85 -0
  38. package/server/api/routes/agent.ts +478 -0
  39. package/server/api/routes/chart.ts +98 -0
  40. package/server/api/routes/chat-index.ts +46 -0
  41. package/server/api/routes/config.ts +258 -0
  42. package/server/api/routes/dispatchResponse.ts +79 -0
  43. package/server/api/routes/files.ts +812 -0
  44. package/server/api/routes/html.ts +101 -0
  45. package/server/api/routes/image.ts +169 -0
  46. package/server/api/routes/mulmo-script.ts +712 -0
  47. package/server/api/routes/mulmoScriptValidate.ts +101 -0
  48. package/server/api/routes/notifications.ts +69 -0
  49. package/server/api/routes/pdf.ts +163 -0
  50. package/server/api/routes/plugins.ts +276 -0
  51. package/server/api/routes/presentHtml.ts +48 -0
  52. package/server/api/routes/roles.ts +125 -0
  53. package/server/api/routes/scheduler.ts +153 -0
  54. package/server/api/routes/schedulerHandlers.ts +151 -0
  55. package/server/api/routes/schedulerTasks.ts +163 -0
  56. package/server/api/routes/sessions.ts +294 -0
  57. package/server/api/routes/sessionsCursor.ts +59 -0
  58. package/server/api/routes/skills.ts +195 -0
  59. package/server/api/routes/sources.ts +540 -0
  60. package/server/api/routes/todos.ts +263 -0
  61. package/server/api/routes/todosColumnsHandlers.ts +347 -0
  62. package/server/api/routes/todosHandlers.ts +274 -0
  63. package/server/api/routes/todosItemsHandlers.ts +386 -0
  64. package/server/api/routes/wiki/pageIndex.ts +53 -0
  65. package/server/api/routes/wiki.ts +363 -0
  66. package/server/api/sandboxStatus.ts +64 -0
  67. package/server/events/notifications.ts +160 -0
  68. package/server/events/pub-sub/index.ts +45 -0
  69. package/server/events/relay-client.ts +288 -0
  70. package/server/events/scheduler-adapter.ts +302 -0
  71. package/server/events/session-store/index.ts +492 -0
  72. package/server/events/task-manager/index.ts +181 -0
  73. package/server/index.ts +572 -0
  74. package/server/system/config.ts +243 -0
  75. package/server/system/credentials.ts +220 -0
  76. package/server/system/docker.ts +97 -0
  77. package/server/system/env.ts +109 -0
  78. package/server/system/logger/config.ts +112 -0
  79. package/server/system/logger/formatters.ts +40 -0
  80. package/server/system/logger/index.ts +53 -0
  81. package/server/system/logger/rotation.ts +37 -0
  82. package/server/system/logger/sinks.ts +101 -0
  83. package/server/system/logger/types.ts +29 -0
  84. package/server/utils/date.ts +57 -0
  85. package/server/utils/errors.ts +7 -0
  86. package/server/utils/fetch.ts +27 -0
  87. package/server/utils/files/atomic.ts +125 -0
  88. package/server/utils/files/html-io.ts +20 -0
  89. package/server/utils/files/image-store.ts +66 -0
  90. package/server/utils/files/index.ts +45 -0
  91. package/server/utils/files/journal-io.ts +213 -0
  92. package/server/utils/files/json.ts +69 -0
  93. package/server/utils/files/markdown-store.ts +33 -0
  94. package/server/utils/files/naming.ts +50 -0
  95. package/server/utils/files/reference-dirs-io.ts +45 -0
  96. package/server/utils/files/roles-io.ts +45 -0
  97. package/server/utils/files/safe.ts +106 -0
  98. package/server/utils/files/scheduler-io.ts +20 -0
  99. package/server/utils/files/scheduler-overrides-io.ts +64 -0
  100. package/server/utils/files/session-io.ts +136 -0
  101. package/server/utils/files/spreadsheet-store.ts +63 -0
  102. package/server/utils/files/todos-io.ts +29 -0
  103. package/server/utils/files/user-tasks-io.ts +25 -0
  104. package/server/utils/files/workspace-io.ts +221 -0
  105. package/server/utils/gemini.ts +59 -0
  106. package/server/utils/gitignore.ts +69 -0
  107. package/server/utils/http.ts +15 -0
  108. package/server/utils/httpError.ts +61 -0
  109. package/server/utils/id.ts +16 -0
  110. package/server/utils/json.ts +83 -0
  111. package/server/utils/logBackgroundError.ts +22 -0
  112. package/server/utils/markdown.ts +82 -0
  113. package/server/utils/request.ts +29 -0
  114. package/server/utils/slug.ts +50 -0
  115. package/server/utils/spawn.ts +62 -0
  116. package/server/utils/time.ts +34 -0
  117. package/server/utils/types.ts +47 -0
  118. package/server/workspace/chat-index/index.ts +153 -0
  119. package/server/workspace/chat-index/indexer.ts +209 -0
  120. package/server/workspace/chat-index/paths.ts +34 -0
  121. package/server/workspace/chat-index/summarizer.ts +247 -0
  122. package/server/workspace/chat-index/types.ts +38 -0
  123. package/server/workspace/custom-dirs.ts +220 -0
  124. package/server/workspace/helps/business.md +104 -0
  125. package/server/workspace/helps/github.md +23 -0
  126. package/server/workspace/helps/index.md +60 -0
  127. package/server/workspace/helps/mulmoscript.md +249 -0
  128. package/server/workspace/helps/sandbox.md +90 -0
  129. package/server/workspace/helps/spreadsheet.md +43 -0
  130. package/server/workspace/helps/telegram.md +135 -0
  131. package/server/workspace/helps/wiki.md +131 -0
  132. package/server/workspace/journal/archivist.ts +386 -0
  133. package/server/workspace/journal/dailyPass.ts +743 -0
  134. package/server/workspace/journal/diff.ts +71 -0
  135. package/server/workspace/journal/index.ts +185 -0
  136. package/server/workspace/journal/indexFile.ts +136 -0
  137. package/server/workspace/journal/linkRewrite.ts +4 -0
  138. package/server/workspace/journal/memoryExtractor.ts +130 -0
  139. package/server/workspace/journal/optimizationPass.ts +160 -0
  140. package/server/workspace/journal/paths.ts +76 -0
  141. package/server/workspace/journal/state.ts +125 -0
  142. package/server/workspace/paths.ts +158 -0
  143. package/server/workspace/reference-dirs.ts +252 -0
  144. package/server/workspace/roles.ts +37 -0
  145. package/server/workspace/skills/discovery.ts +125 -0
  146. package/server/workspace/skills/index.ts +10 -0
  147. package/server/workspace/skills/parser.ts +144 -0
  148. package/server/workspace/skills/paths.ts +41 -0
  149. package/server/workspace/skills/scheduler.ts +149 -0
  150. package/server/workspace/skills/types.ts +30 -0
  151. package/server/workspace/skills/user-tasks.ts +257 -0
  152. package/server/workspace/skills/writer.ts +189 -0
  153. package/server/workspace/sources/arxivDiscovery.ts +182 -0
  154. package/server/workspace/sources/classifier.ts +268 -0
  155. package/server/workspace/sources/fetchers/arxiv.ts +170 -0
  156. package/server/workspace/sources/fetchers/github.ts +106 -0
  157. package/server/workspace/sources/fetchers/githubIssues.ts +208 -0
  158. package/server/workspace/sources/fetchers/githubReleases.ts +186 -0
  159. package/server/workspace/sources/fetchers/index.ts +71 -0
  160. package/server/workspace/sources/fetchers/registerAll.ts +15 -0
  161. package/server/workspace/sources/fetchers/rss.ts +141 -0
  162. package/server/workspace/sources/fetchers/rssParser.ts +295 -0
  163. package/server/workspace/sources/httpFetcher.ts +230 -0
  164. package/server/workspace/sources/interests.ts +120 -0
  165. package/server/workspace/sources/paths.ts +110 -0
  166. package/server/workspace/sources/pipeline/dedup.ts +60 -0
  167. package/server/workspace/sources/pipeline/fetch.ts +136 -0
  168. package/server/workspace/sources/pipeline/index.ts +249 -0
  169. package/server/workspace/sources/pipeline/notify.ts +72 -0
  170. package/server/workspace/sources/pipeline/plan.ts +66 -0
  171. package/server/workspace/sources/pipeline/summarize.ts +189 -0
  172. package/server/workspace/sources/pipeline/write.ts +185 -0
  173. package/server/workspace/sources/rateLimiter.ts +148 -0
  174. package/server/workspace/sources/registry.ts +326 -0
  175. package/server/workspace/sources/robots.ts +271 -0
  176. package/server/workspace/sources/sourceState.ts +135 -0
  177. package/server/workspace/sources/taxonomy.ts +74 -0
  178. package/server/workspace/sources/types.ts +144 -0
  179. package/server/workspace/sources/urls.ts +112 -0
  180. package/server/workspace/tool-trace/classify.ts +114 -0
  181. package/server/workspace/tool-trace/index.ts +250 -0
  182. package/server/workspace/tool-trace/writeSearch.ts +98 -0
  183. package/server/workspace/wiki-backlinks/index.ts +107 -0
  184. package/server/workspace/wiki-backlinks/sessionBacklinks.ts +144 -0
  185. package/server/workspace/workspace.ts +66 -0
  186. package/src/App.vue +720 -0
  187. package/src/assets/mulmo_bw.png +0 -0
  188. package/src/components/CanvasViewToggle.vue +27 -0
  189. package/src/components/ChatAttachmentPreview.vue +45 -0
  190. package/src/components/ChatImagePreview.vue +17 -0
  191. package/src/components/ChatInput.vue +208 -0
  192. package/src/components/FileContentHeader.vue +49 -0
  193. package/src/components/FileContentRenderer.vue +162 -0
  194. package/src/components/FileTree.vue +115 -0
  195. package/src/components/FileTreePane.vue +85 -0
  196. package/src/components/FilesView.vue +206 -0
  197. package/src/components/LockStatusPopup.vue +111 -0
  198. package/src/components/NotificationBell.vue +131 -0
  199. package/src/components/NotificationToast.vue +72 -0
  200. package/src/components/PluginLauncher.vue +138 -0
  201. package/src/components/RightSidebar.vue +113 -0
  202. package/src/components/RoleSelector.vue +64 -0
  203. package/src/components/SessionHistoryPanel.vue +176 -0
  204. package/src/components/SessionTabBar.vue +81 -0
  205. package/src/components/SettingsMcpTab.vue +350 -0
  206. package/src/components/SettingsModal.vue +275 -0
  207. package/src/components/SettingsReferenceDirsTab.vue +173 -0
  208. package/src/components/SettingsWorkspaceDirsTab.vue +174 -0
  209. package/src/components/SidebarHeader.vue +69 -0
  210. package/src/components/StackView.vue +360 -0
  211. package/src/components/SuggestionsPanel.vue +65 -0
  212. package/src/components/TodoExplorer.vue +358 -0
  213. package/src/components/ToolResultsPanel.vue +77 -0
  214. package/src/components/todo/TodoAddDialog.vue +131 -0
  215. package/src/components/todo/TodoEditDialog.vue +47 -0
  216. package/src/components/todo/TodoEditPanel.vue +113 -0
  217. package/src/components/todo/TodoKanbanView.vue +249 -0
  218. package/src/components/todo/TodoListView.vue +79 -0
  219. package/src/components/todo/TodoTableView.vue +177 -0
  220. package/src/composables/useActiveSession.ts +40 -0
  221. package/src/composables/useAppApi.ts +45 -0
  222. package/src/composables/useCanvasViewMode.ts +121 -0
  223. package/src/composables/useChatScroll.ts +47 -0
  224. package/src/composables/useClickOutside.ts +26 -0
  225. package/src/composables/useClipboardCopy.ts +44 -0
  226. package/src/composables/useContentDisplay.ts +52 -0
  227. package/src/composables/useDebugBeat.ts +23 -0
  228. package/src/composables/useDynamicFavicon.ts +115 -0
  229. package/src/composables/useEventListeners.ts +42 -0
  230. package/src/composables/useExpandedDirs.ts +64 -0
  231. package/src/composables/useFaviconState.ts +30 -0
  232. package/src/composables/useFileSelection.ts +115 -0
  233. package/src/composables/useFileSortMode.ts +24 -0
  234. package/src/composables/useFileTree.ts +85 -0
  235. package/src/composables/useFreshPluginData.ts +89 -0
  236. package/src/composables/useHealth.ts +38 -0
  237. package/src/composables/useImeAwareEnter.ts +57 -0
  238. package/src/composables/useKeyNavigation.ts +60 -0
  239. package/src/composables/useMarkdownLinkHandler.ts +46 -0
  240. package/src/composables/useMarkdownMode.ts +17 -0
  241. package/src/composables/useMcpTools.ts +71 -0
  242. package/src/composables/useMergedSessions.ts +27 -0
  243. package/src/composables/useNotifications.ts +90 -0
  244. package/src/composables/usePdfDownload.ts +60 -0
  245. package/src/composables/usePendingCalls.ts +77 -0
  246. package/src/composables/usePubSub.ts +85 -0
  247. package/src/composables/useRightSidebar.ts +23 -0
  248. package/src/composables/useRoles.ts +34 -0
  249. package/src/composables/useSandboxStatus.ts +67 -0
  250. package/src/composables/useSelectedResult.ts +49 -0
  251. package/src/composables/useSessionDerived.ts +51 -0
  252. package/src/composables/useSessionHistory.ts +81 -0
  253. package/src/composables/useSessionSync.ts +57 -0
  254. package/src/composables/useViewLayout.ts +55 -0
  255. package/src/config/apiRoutes.ts +173 -0
  256. package/src/config/pubsubChannels.ts +45 -0
  257. package/src/config/roles.ts +335 -0
  258. package/src/config/schedulerActions.ts +25 -0
  259. package/src/config/toolNames.ts +71 -0
  260. package/src/config/workspacePaths.ts +24 -0
  261. package/src/index.css +107 -0
  262. package/src/main.ts +25 -0
  263. package/src/plugins/canvas/Preview.vue +13 -0
  264. package/src/plugins/canvas/View.vue +333 -0
  265. package/src/plugins/canvas/definition.ts +38 -0
  266. package/src/plugins/canvas/index.ts +36 -0
  267. package/src/plugins/chart/Preview.vue +49 -0
  268. package/src/plugins/chart/View.vue +143 -0
  269. package/src/plugins/chart/definition.ts +58 -0
  270. package/src/plugins/chart/index.ts +52 -0
  271. package/src/plugins/editImage/Preview.vue +13 -0
  272. package/src/plugins/editImage/View.vue +13 -0
  273. package/src/plugins/editImage/definition.ts +27 -0
  274. package/src/plugins/editImage/index.ts +36 -0
  275. package/src/plugins/generateImage/Preview.vue +13 -0
  276. package/src/plugins/generateImage/View.vue +33 -0
  277. package/src/plugins/generateImage/definition.ts +32 -0
  278. package/src/plugins/generateImage/index.ts +56 -0
  279. package/src/plugins/manageRoles/Preview.vue +49 -0
  280. package/src/plugins/manageRoles/View.vue +525 -0
  281. package/src/plugins/manageRoles/definition.ts +43 -0
  282. package/src/plugins/manageRoles/index.ts +47 -0
  283. package/src/plugins/manageSkills/Preview.vue +21 -0
  284. package/src/plugins/manageSkills/View.vue +321 -0
  285. package/src/plugins/manageSkills/definition.ts +49 -0
  286. package/src/plugins/manageSkills/index.ts +49 -0
  287. package/src/plugins/manageSource/Preview.vue +33 -0
  288. package/src/plugins/manageSource/View.vue +697 -0
  289. package/src/plugins/manageSource/definition.ts +63 -0
  290. package/src/plugins/manageSource/index.ts +66 -0
  291. package/src/plugins/markdown/Preview.vue +77 -0
  292. package/src/plugins/markdown/View.vue +476 -0
  293. package/src/plugins/markdown/definition.ts +50 -0
  294. package/src/plugins/markdown/index.ts +36 -0
  295. package/src/plugins/presentHtml/Preview.vue +25 -0
  296. package/src/plugins/presentHtml/View.vue +52 -0
  297. package/src/plugins/presentHtml/definition.ts +27 -0
  298. package/src/plugins/presentHtml/helpers.ts +72 -0
  299. package/src/plugins/presentHtml/index.ts +41 -0
  300. package/src/plugins/presentMulmoScript/Preview.vue +23 -0
  301. package/src/plugins/presentMulmoScript/View.vue +1166 -0
  302. package/src/plugins/presentMulmoScript/definition.ts +95 -0
  303. package/src/plugins/presentMulmoScript/helpers.ts +162 -0
  304. package/src/plugins/presentMulmoScript/index.ts +40 -0
  305. package/src/plugins/scheduler/Preview.vue +67 -0
  306. package/src/plugins/scheduler/TasksTab.vue +205 -0
  307. package/src/plugins/scheduler/View.vue +565 -0
  308. package/src/plugins/scheduler/definition.ts +57 -0
  309. package/src/plugins/scheduler/index.ts +45 -0
  310. package/src/plugins/scheduler/viewModes.ts +26 -0
  311. package/src/plugins/spreadsheet/Preview.vue +29 -0
  312. package/src/plugins/spreadsheet/View.vue +997 -0
  313. package/src/plugins/spreadsheet/cellHighlights.ts +79 -0
  314. package/src/plugins/spreadsheet/definition.ts +121 -0
  315. package/src/plugins/spreadsheet/engine/calculator.ts +459 -0
  316. package/src/plugins/spreadsheet/engine/cellBuilder.ts +81 -0
  317. package/src/plugins/spreadsheet/engine/date-parser.ts +220 -0
  318. package/src/plugins/spreadsheet/engine/date-utils.ts +56 -0
  319. package/src/plugins/spreadsheet/engine/engine.ts +176 -0
  320. package/src/plugins/spreadsheet/engine/evaluator.ts +390 -0
  321. package/src/plugins/spreadsheet/engine/formatter.ts +172 -0
  322. package/src/plugins/spreadsheet/engine/formulaRefs.ts +101 -0
  323. package/src/plugins/spreadsheet/engine/functions/date.ts +299 -0
  324. package/src/plugins/spreadsheet/engine/functions/financial.ts +387 -0
  325. package/src/plugins/spreadsheet/engine/functions/index.ts +16 -0
  326. package/src/plugins/spreadsheet/engine/functions/logical.ts +262 -0
  327. package/src/plugins/spreadsheet/engine/functions/lookup.ts +400 -0
  328. package/src/plugins/spreadsheet/engine/functions/mathematical.ts +297 -0
  329. package/src/plugins/spreadsheet/engine/functions/statistical.ts +338 -0
  330. package/src/plugins/spreadsheet/engine/functions/text.ts +389 -0
  331. package/src/plugins/spreadsheet/engine/index.ts +27 -0
  332. package/src/plugins/spreadsheet/engine/jsonCellLocator.ts +111 -0
  333. package/src/plugins/spreadsheet/engine/parser.ts +143 -0
  334. package/src/plugins/spreadsheet/engine/registry.ts +150 -0
  335. package/src/plugins/spreadsheet/engine/responseDecoder.ts +67 -0
  336. package/src/plugins/spreadsheet/engine/types.ts +64 -0
  337. package/src/plugins/spreadsheet/index.ts +36 -0
  338. package/src/plugins/textResponse/Preview.vue +94 -0
  339. package/src/plugins/textResponse/View.vue +503 -0
  340. package/src/plugins/textResponse/definition.ts +34 -0
  341. package/src/plugins/textResponse/index.ts +27 -0
  342. package/src/plugins/textResponse/plugin.ts +29 -0
  343. package/src/plugins/textResponse/samples.ts +97 -0
  344. package/src/plugins/textResponse/types.ts +11 -0
  345. package/src/plugins/todo/Preview.vue +63 -0
  346. package/src/plugins/todo/View.vue +364 -0
  347. package/src/plugins/todo/composables/useTodos.ts +177 -0
  348. package/src/plugins/todo/definition.ts +45 -0
  349. package/src/plugins/todo/index.ts +61 -0
  350. package/src/plugins/todo/labels.ts +163 -0
  351. package/src/plugins/todo/priority.ts +98 -0
  352. package/src/plugins/todo/viewModes.ts +19 -0
  353. package/src/plugins/ui-image/ImagePreview.vue +23 -0
  354. package/src/plugins/ui-image/ImageView.vue +34 -0
  355. package/src/plugins/ui-image/index.ts +3 -0
  356. package/src/plugins/ui-image/types.ts +4 -0
  357. package/src/plugins/wiki/Preview.vue +65 -0
  358. package/src/plugins/wiki/View.vue +342 -0
  359. package/src/plugins/wiki/definition.ts +25 -0
  360. package/src/plugins/wiki/helpers.ts +59 -0
  361. package/src/plugins/wiki/index.ts +52 -0
  362. package/src/router/guards.ts +61 -0
  363. package/src/router/index.ts +50 -0
  364. package/src/tools/index.ts +52 -0
  365. package/src/tools/types.ts +27 -0
  366. package/src/types/events.ts +16 -0
  367. package/src/types/fileTree.ts +13 -0
  368. package/src/types/notification.ts +67 -0
  369. package/src/types/session.ts +116 -0
  370. package/src/types/sse.ts +90 -0
  371. package/src/types/toolCallHistory.ts +13 -0
  372. package/src/utils/agent/eventDispatch.ts +74 -0
  373. package/src/utils/agent/request.ts +55 -0
  374. package/src/utils/agent/toolCalls.ts +62 -0
  375. package/src/utils/api.ts +218 -0
  376. package/src/utils/canvas/viewMode.ts +46 -0
  377. package/src/utils/dom/authTokenMeta.ts +20 -0
  378. package/src/utils/dom/clickOutside.ts +11 -0
  379. package/src/utils/dom/externalLink.ts +57 -0
  380. package/src/utils/dom/scrollable.ts +24 -0
  381. package/src/utils/errors.ts +11 -0
  382. package/src/utils/files/expandedDirs.ts +25 -0
  383. package/src/utils/files/filename.ts +12 -0
  384. package/src/utils/files/sortChildren.ts +20 -0
  385. package/src/utils/filesPreview/schedulerPreview.ts +38 -0
  386. package/src/utils/filesPreview/todoPreview.ts +40 -0
  387. package/src/utils/format/date.ts +85 -0
  388. package/src/utils/format/frontmatter.ts +80 -0
  389. package/src/utils/format/jsonSyntax.ts +109 -0
  390. package/src/utils/html/previewCsp.ts +65 -0
  391. package/src/utils/image/resolve.ts +8 -0
  392. package/src/utils/image/rewriteMarkdownImageRefs.ts +182 -0
  393. package/src/utils/markdown/extractFirstH1.ts +39 -0
  394. package/src/utils/notification/dispatch.ts +22 -0
  395. package/src/utils/path/relativeLink.ts +130 -0
  396. package/src/utils/role/icon.ts +20 -0
  397. package/src/utils/role/merge.ts +10 -0
  398. package/src/utils/role/plugins.ts +12 -0
  399. package/src/utils/session/mergeSessions.ts +103 -0
  400. package/src/utils/session/seedRoleDefault.ts +35 -0
  401. package/src/utils/session/sessionEntries.ts +121 -0
  402. package/src/utils/session/sessionFactory.ts +22 -0
  403. package/src/utils/session/sessionHelpers.ts +99 -0
  404. package/src/utils/tools/dedup.ts +17 -0
  405. package/src/utils/tools/mcp.ts +33 -0
  406. package/src/utils/tools/pendingCalls.ts +16 -0
  407. package/src/utils/tools/result.ts +40 -0
  408. package/src/utils/types.ts +44 -0
@@ -0,0 +1,274 @@
1
+ // Pure action handlers for the todos POST route. Same shape as
2
+ // schedulerHandlers.ts: each handler takes the current items + the
3
+ // relevant body fields and returns a discriminated result describing
4
+ // either an HTTP error or the next state. The route handler in
5
+ // todos.ts dispatches to one of these and translates the result into
6
+ // an HTTP response.
7
+ //
8
+ // Keeping the action logic pure (no I/O, no globals) makes every
9
+ // case unit-testable in isolation, and brings the cognitive
10
+ // complexity of the route handler under the lint threshold.
11
+
12
+ import type { TodoItem } from "./todos.js";
13
+ import { filterByLabels, listLabelsWithCount, mergeLabels, subtractLabels } from "../../../src/plugins/todo/labels.js";
14
+ import { makeId } from "../../utils/id.js";
15
+
16
+ export interface TodosActionInput {
17
+ text?: string;
18
+ newText?: string;
19
+ note?: string;
20
+ // For `add`: labels to tag the new item with.
21
+ // For `add_label` / `remove_label`: labels to add to / remove from
22
+ // the item matched by `text`.
23
+ labels?: string[];
24
+ // For `show`: OR-semantics filter that restricts the returned list
25
+ // to items carrying at least one of these labels (case-insensitive).
26
+ filterLabels?: string[];
27
+ }
28
+
29
+ export type TodosActionResult =
30
+ | { kind: "error"; status: number; error: string }
31
+ | {
32
+ kind: "success";
33
+ items: TodoItem[];
34
+ message: string;
35
+ jsonData: Record<string, unknown>;
36
+ };
37
+
38
+ // Substring match (case-insensitive). Used by delete / update /
39
+ // check / uncheck / add_label / remove_label — all share the same
40
+ // lookup contract.
41
+ export function findTodoByText(items: TodoItem[], text: string): TodoItem | undefined {
42
+ const needle = text.toLowerCase();
43
+ return items.find((i) => i.text.toLowerCase().includes(needle));
44
+ }
45
+
46
+ export function handleShow(items: TodoItem[], input: TodosActionInput): TodosActionResult {
47
+ const filterLabels = input.filterLabels ?? [];
48
+ const filtered = filterByLabels(items, filterLabels);
49
+ const filtering = filterLabels.length > 0;
50
+ const message = filtering
51
+ ? `Showing ${filtered.length} of ${items.length} todo item(s) filtered by: ${filterLabels.join(", ")}`
52
+ : `Showing ${items.length} todo item(s)`;
53
+ return {
54
+ kind: "success",
55
+ items: filtered,
56
+ message,
57
+ jsonData: {
58
+ items: filtered.map((i) => ({
59
+ text: i.text,
60
+ completed: i.completed,
61
+ ...(i.labels && i.labels.length > 0 && { labels: i.labels }),
62
+ })),
63
+ },
64
+ };
65
+ }
66
+
67
+ export function handleAdd(items: TodoItem[], input: TodosActionInput): TodosActionResult {
68
+ if (!input.text) {
69
+ return { kind: "error", status: 400, error: "text required" };
70
+ }
71
+ // Normalise incoming labels by routing them through
72
+ // `mergeLabels([], labels ?? [])` — that handles trim / collapse /
73
+ // case-insensitive dedup in one shot.
74
+ const normalizedLabels = mergeLabels([], input.labels ?? []);
75
+ const item: TodoItem = {
76
+ id: makeId("todo"),
77
+ text: input.text,
78
+ ...(input.note !== undefined && { note: input.note }),
79
+ ...(normalizedLabels.length > 0 && { labels: normalizedLabels }),
80
+ completed: false,
81
+ createdAt: Date.now(),
82
+ };
83
+ return {
84
+ kind: "success",
85
+ items: [...items, item],
86
+ message: normalizedLabels.length > 0 ? `Added: "${input.text}" [${normalizedLabels.join(", ")}]` : `Added: "${input.text}"`,
87
+ jsonData: { added: input.text, labels: normalizedLabels },
88
+ };
89
+ }
90
+
91
+ export function handleDelete(items: TodoItem[], input: TodosActionInput): TodosActionResult {
92
+ if (!input.text) {
93
+ return { kind: "error", status: 400, error: "text required" };
94
+ }
95
+ const needle = input.text.toLowerCase();
96
+ const next = items.filter((i) => !i.text.toLowerCase().includes(needle));
97
+ const found = next.length < items.length;
98
+ return {
99
+ kind: "success",
100
+ items: next,
101
+ message: found ? `Deleted: "${input.text}"` : `Item not found: "${input.text}"`,
102
+ jsonData: { deleted: input.text },
103
+ };
104
+ }
105
+
106
+ export function handleUpdate(items: TodoItem[], input: TodosActionInput): TodosActionResult {
107
+ if (!input.text || !input.newText) {
108
+ return { kind: "error", status: 400, error: "text and newText required" };
109
+ }
110
+ const target = findTodoByText(items, input.text);
111
+ if (!target) {
112
+ return {
113
+ kind: "success",
114
+ items,
115
+ message: `Item not found: "${input.text}"`,
116
+ jsonData: {},
117
+ };
118
+ }
119
+ const oldText = target.text;
120
+ const updated: TodoItem = {
121
+ ...target,
122
+ text: input.newText,
123
+ note: input.note !== undefined ? input.note || undefined : target.note,
124
+ };
125
+ const next = items.map((i) => (i.id === target.id ? updated : i));
126
+ return {
127
+ kind: "success",
128
+ items: next,
129
+ message: `Updated: "${oldText}" → "${input.newText}"`,
130
+ jsonData: { oldText, newText: input.newText },
131
+ };
132
+ }
133
+
134
+ function setCompleted(
135
+ items: TodoItem[],
136
+ input: TodosActionInput,
137
+ completed: boolean,
138
+ verb: "Checked" | "Unchecked",
139
+ jsonKey: "checkedItem" | "uncheckedItem",
140
+ ): TodosActionResult {
141
+ if (!input.text) {
142
+ return { kind: "error", status: 400, error: "text required" };
143
+ }
144
+ const target = findTodoByText(items, input.text);
145
+ if (!target) {
146
+ return {
147
+ kind: "success",
148
+ items,
149
+ message: `Item not found: "${input.text}"`,
150
+ jsonData: {},
151
+ };
152
+ }
153
+ const updated: TodoItem = { ...target, completed };
154
+ const next = items.map((i) => (i.id === target.id ? updated : i));
155
+ return {
156
+ kind: "success",
157
+ items: next,
158
+ message: `${verb}: "${target.text}"`,
159
+ jsonData: { [jsonKey]: target.text },
160
+ };
161
+ }
162
+
163
+ export function handleCheck(items: TodoItem[], input: TodosActionInput): TodosActionResult {
164
+ return setCompleted(items, input, true, "Checked", "checkedItem");
165
+ }
166
+
167
+ export function handleUncheck(items: TodoItem[], input: TodosActionInput): TodosActionResult {
168
+ return setCompleted(items, input, false, "Unchecked", "uncheckedItem");
169
+ }
170
+
171
+ export function handleClearCompleted(items: TodoItem[]): TodosActionResult {
172
+ const count = items.filter((i) => i.completed).length;
173
+ const next = items.filter((i) => !i.completed);
174
+ return {
175
+ kind: "success",
176
+ items: next,
177
+ message: `Cleared ${count} completed item(s)`,
178
+ jsonData: { clearedCount: count },
179
+ };
180
+ }
181
+
182
+ export function handleAddLabel(items: TodoItem[], input: TodosActionInput): TodosActionResult {
183
+ if (!input.text || !input.labels || input.labels.length === 0) {
184
+ return {
185
+ kind: "error",
186
+ status: 400,
187
+ error: "text and a non-empty labels array required",
188
+ };
189
+ }
190
+ const target = findTodoByText(items, input.text);
191
+ if (!target) {
192
+ return {
193
+ kind: "success",
194
+ items,
195
+ message: `Item not found: "${input.text}"`,
196
+ jsonData: { notFound: input.text },
197
+ };
198
+ }
199
+ const merged = mergeLabels(target.labels ?? [], input.labels);
200
+ const updated: TodoItem = { ...target, labels: merged };
201
+ const next = items.map((i) => (i.id === target.id ? updated : i));
202
+ return {
203
+ kind: "success",
204
+ items: next,
205
+ message: `Labels on "${target.text}": ${merged.join(", ")}`,
206
+ jsonData: { item: target.text, labels: merged },
207
+ };
208
+ }
209
+
210
+ export function handleRemoveLabel(items: TodoItem[], input: TodosActionInput): TodosActionResult {
211
+ if (!input.text || !input.labels || input.labels.length === 0) {
212
+ return {
213
+ kind: "error",
214
+ status: 400,
215
+ error: "text and a non-empty labels array required",
216
+ };
217
+ }
218
+ const target = findTodoByText(items, input.text);
219
+ if (!target) {
220
+ return {
221
+ kind: "success",
222
+ items,
223
+ message: `Item not found: "${input.text}"`,
224
+ jsonData: { notFound: input.text },
225
+ };
226
+ }
227
+ const remaining = subtractLabels(target.labels ?? [], input.labels);
228
+ const updated: TodoItem = { ...target };
229
+ if (remaining.length > 0) {
230
+ updated.labels = remaining;
231
+ } else {
232
+ delete updated.labels;
233
+ }
234
+ const next = items.map((i) => (i.id === target.id ? updated : i));
235
+ return {
236
+ kind: "success",
237
+ items: next,
238
+ message: remaining.length > 0 ? `Labels on "${target.text}": ${remaining.join(", ")}` : `"${target.text}" now has no labels`,
239
+ jsonData: { item: target.text, labels: remaining },
240
+ };
241
+ }
242
+
243
+ export function handleListLabels(items: TodoItem[]): TodosActionResult {
244
+ const inventory = listLabelsWithCount(items);
245
+ const summary = inventory.map((l) => `${l.label} (${l.count})`).join(", ");
246
+ const message = inventory.length === 0 ? "No labels in use" : `${inventory.length} label(s) in use: ${summary}`;
247
+ return {
248
+ kind: "success",
249
+ items,
250
+ message,
251
+ jsonData: { labels: inventory },
252
+ };
253
+ }
254
+
255
+ const HANDLERS: Record<string, (items: TodoItem[], input: TodosActionInput) => TodosActionResult> = {
256
+ show: handleShow,
257
+ add: handleAdd,
258
+ delete: handleDelete,
259
+ update: handleUpdate,
260
+ check: handleCheck,
261
+ uncheck: handleUncheck,
262
+ clear_completed: handleClearCompleted,
263
+ add_label: handleAddLabel,
264
+ remove_label: handleRemoveLabel,
265
+ list_labels: handleListLabels,
266
+ };
267
+
268
+ export function dispatchTodos(action: string, items: TodoItem[], input: TodosActionInput): TodosActionResult {
269
+ const handler = HANDLERS[action];
270
+ if (!handler) {
271
+ return { kind: "error", status: 400, error: `Unknown action: ${action}` };
272
+ }
273
+ return handler(items, input);
274
+ }
@@ -0,0 +1,386 @@
1
+ // Pure handlers for the id-based REST routes used by the file-explorer
2
+ // todo view (TodoExplorer.vue). The MCP `manageTodoList` action route
3
+ // continues to live in todosHandlers.ts; these handlers are intended
4
+ // for the web UI which knows item ids directly and doesn't need the
5
+ // substring-match contract that the MCP handlers use.
6
+ //
7
+ // Each function takes the current items + columns + an input record
8
+ // and returns a result. The Express route is responsible for loading
9
+ // and saving JSON to disk.
10
+
11
+ import type { TodoItem, TodoPriority } from "./todos.js";
12
+ import { type StatusColumn, defaultStatusId, doneColumnId } from "./todosColumnsHandlers.js";
13
+ import { mergeLabels } from "../../../src/plugins/todo/labels.js";
14
+ import { makeId } from "../../utils/id.js";
15
+
16
+ const ORDER_STEP = 1000;
17
+ const PRIORITIES: readonly TodoPriority[] = ["low", "medium", "high", "urgent"];
18
+
19
+ // ── Result type ───────────────────────────────────────────────────
20
+
21
+ export type ItemsActionResult = { kind: "error"; status: number; error: string } | { kind: "success"; items: TodoItem[]; item?: TodoItem };
22
+
23
+ // ── Migration ─────────────────────────────────────────────────────
24
+
25
+ // Backfill `status` and `order` on items that pre-date the kanban
26
+ // extension. Pure / idempotent: items that already have valid values
27
+ // pass through unchanged. Done via a single forward pass that assigns
28
+ // monotonically-increasing order values per status column so the
29
+ // kanban view has a stable initial sort.
30
+ //
31
+ // Reasoning for the order assignment: legacy items only carry a
32
+ // `createdAt`, and we want oldest-first within each column. We can't
33
+ // just use createdAt as the order key because it's a milliseconds
34
+ // number which makes hand-editing painful and conflicts with the
35
+ // 1000-step convention drag-drop uses for new items.
36
+ export function migrateItems(rawItems: TodoItem[], columns: StatusColumn[]): TodoItem[] {
37
+ const doneId = doneColumnId(columns);
38
+ const openId = defaultStatusId(columns);
39
+ const validStatusIds = new Set(columns.map((column) => column.id));
40
+
41
+ // First pass: backfill status. Items pointing at a column that no
42
+ // longer exists are reassigned to the default open or done column
43
+ // depending on `completed`.
44
+ //
45
+ // Note: we deliberately do NOT re-sync `completed` to status on
46
+ // every read. Earlier versions of this function did, but that
47
+ // overrode the legacy MCP `check` / `uncheck` actions — those
48
+ // actions only flip the boolean, never touch status, so a sync
49
+ // pass kept reverting them on the next read. Treating the two
50
+ // fields as independent at the storage layer leaves both the REST
51
+ // PATCH path (which keeps them in sync explicitly) and the legacy
52
+ // MCP actions (which only touch `completed`) working correctly.
53
+ const withStatus = rawItems.map((item): TodoItem => {
54
+ const hasValidStatus = typeof item.status === "string" && validStatusIds.has(item.status);
55
+ if (hasValidStatus) return item;
56
+ const status = item.completed ? doneId : openId;
57
+ return { ...item, status };
58
+ });
59
+
60
+ // Second pass: backfill order per column. Items that already have
61
+ // an order keep item untouched — only items missing order get one
62
+ // assigned, and they go after the column's current max so they
63
+ // sort to the bottom in createdAt order. This preserves any
64
+ // hand-managed ordering even when a column is a mix of legacy
65
+ // and kanban-aware items.
66
+ const byStatus = new Map<string, TodoItem[]>();
67
+ for (const item of withStatus) {
68
+ const key = item.status ?? openId;
69
+ if (!byStatus.has(key)) byStatus.set(key, []);
70
+ byStatus.get(key)!.push(item);
71
+ }
72
+ const orderById = new Map<string, number>();
73
+ for (const [, group] of byStatus) {
74
+ const missing = group.filter((item) => typeof item.order !== "number");
75
+ if (missing.length === 0) continue;
76
+ const existingMax = group.filter((item) => typeof item.order === "number").reduce((acc, item) => Math.max(acc, item.order!), 0);
77
+ const sorted = [...missing].sort((left, right) => left.createdAt - right.createdAt);
78
+ sorted.forEach((item, i) => {
79
+ orderById.set(item.id, existingMax + (i + 1) * ORDER_STEP);
80
+ });
81
+ }
82
+ return withStatus.map((item): TodoItem => {
83
+ const next = orderById.get(item.id);
84
+ if (next === undefined) return item;
85
+ return { ...item, order: next };
86
+ });
87
+ }
88
+
89
+ // ── Validators ────────────────────────────────────────────────────
90
+
91
+ function isPriority(value: unknown): value is TodoPriority {
92
+ return typeof value === "string" && PRIORITIES.includes(value as TodoPriority);
93
+ }
94
+
95
+ // YYYY-MM-DD only — keep item boring so the column is sortable as text.
96
+ function isDueDate(value: unknown): value is string {
97
+ return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value);
98
+ }
99
+
100
+ function nextOrder(items: TodoItem[], statusId: string): number {
101
+ const inColumn = items.filter((item) => item.status === statusId).map((item) => item.order ?? 0);
102
+ if (inColumn.length === 0) return ORDER_STEP;
103
+ return Math.max(...inColumn) + ORDER_STEP;
104
+ }
105
+
106
+ // ── Create ────────────────────────────────────────────────────────
107
+
108
+ export interface CreateInput {
109
+ text?: string;
110
+ note?: string;
111
+ status?: string;
112
+ priority?: string;
113
+ dueDate?: string;
114
+ labels?: string[];
115
+ }
116
+
117
+ // Resolve the status field from input, validating against known
118
+ // columns. Returns the resolved column id or an error result.
119
+ type ResolveStatusResult = { kind: "ok"; status: string } | { kind: "error"; status: number; error: string };
120
+
121
+ function resolveStatus(input: CreateInput, columns: StatusColumn[]): ResolveStatusResult {
122
+ if (input.status === undefined || input.status === "") {
123
+ return { kind: "ok", status: defaultStatusId(columns) };
124
+ }
125
+ const validStatusIds = new Set(columns.map((column) => column.id));
126
+ if (validStatusIds.has(input.status)) {
127
+ return { kind: "ok", status: input.status };
128
+ }
129
+ return {
130
+ kind: "error",
131
+ status: 400,
132
+ error: `unknown status: ${input.status}`,
133
+ };
134
+ }
135
+
136
+ // Apply optional priority + dueDate to an item, returning an error
137
+ // result on validation failure.
138
+ function applyOptionalFields(item: TodoItem, input: CreateInput): ItemsActionResult | null {
139
+ if (input.priority !== undefined && input.priority !== "") {
140
+ if (!isPriority(input.priority)) {
141
+ return { kind: "error", status: 400, error: "invalid priority" };
142
+ }
143
+ item.priority = input.priority;
144
+ }
145
+ if (input.dueDate !== undefined && input.dueDate !== "") {
146
+ if (!isDueDate(input.dueDate)) {
147
+ return {
148
+ kind: "error",
149
+ status: 400,
150
+ error: "dueDate must be YYYY-MM-DD",
151
+ };
152
+ }
153
+ item.dueDate = input.dueDate;
154
+ }
155
+ return null;
156
+ }
157
+
158
+ export function handleCreate(items: TodoItem[], columns: StatusColumn[], input: CreateInput): ItemsActionResult {
159
+ if (!input.text || input.text.trim().length === 0) {
160
+ return { kind: "error", status: 400, error: "text required" };
161
+ }
162
+ const resolved = resolveStatus(input, columns);
163
+ if (resolved.kind === "error") return resolved;
164
+
165
+ const status = resolved.status;
166
+ const item: TodoItem = {
167
+ id: makeId("todo"),
168
+ text: input.text.trim(),
169
+ completed: status === doneColumnId(columns),
170
+ createdAt: Date.now(),
171
+ status,
172
+ order: nextOrder(items, status),
173
+ };
174
+ if (input.note !== undefined && input.note !== "") item.note = input.note;
175
+ const normalizedLabels = mergeLabels([], input.labels ?? []);
176
+ if (normalizedLabels.length > 0) item.labels = normalizedLabels;
177
+
178
+ const fieldError = applyOptionalFields(item, input);
179
+ if (fieldError) return fieldError;
180
+
181
+ return { kind: "success", items: [...items, item], item };
182
+ }
183
+
184
+ // ── Patch ─────────────────────────────────────────────────────────
185
+
186
+ export interface PatchInput {
187
+ text?: string;
188
+ note?: string | null;
189
+ status?: string;
190
+ priority?: string | null;
191
+ dueDate?: string | null;
192
+ labels?: string[];
193
+ completed?: boolean;
194
+ }
195
+
196
+ // Each `applyXxx` helper mutates `updated` in place and returns either
197
+ // `null` (success) or an error result. Splitting them out keeps the
198
+ // top-level `handlePatch` linear so item stays under the cognitive
199
+ // complexity threshold and so each field's edit semantics live in one
200
+ // obvious place.
201
+
202
+ function applyTextPatch(updated: TodoItem, input: PatchInput): ItemsActionResult | null {
203
+ if (typeof input.text !== "string") return null;
204
+ if (input.text.trim().length === 0) {
205
+ return { kind: "error", status: 400, error: "text cannot be empty" };
206
+ }
207
+ updated.text = input.text.trim();
208
+ return null;
209
+ }
210
+
211
+ function applyNotePatch(updated: TodoItem, input: PatchInput): void {
212
+ if (input.note === null || input.note === "") {
213
+ delete updated.note;
214
+ return;
215
+ }
216
+ if (typeof input.note === "string") updated.note = input.note;
217
+ }
218
+
219
+ function applyLabelsPatch(updated: TodoItem, input: PatchInput): void {
220
+ if (!Array.isArray(input.labels)) return;
221
+ const merged = mergeLabels([], input.labels);
222
+ if (merged.length > 0) updated.labels = merged;
223
+ else delete updated.labels;
224
+ }
225
+
226
+ function applyPriorityPatch(updated: TodoItem, input: PatchInput): ItemsActionResult | null {
227
+ if (input.priority === null || input.priority === "") {
228
+ delete updated.priority;
229
+ return null;
230
+ }
231
+ if (input.priority === undefined) return null;
232
+ if (!isPriority(input.priority)) {
233
+ return { kind: "error", status: 400, error: "invalid priority" };
234
+ }
235
+ updated.priority = input.priority;
236
+ return null;
237
+ }
238
+
239
+ function applyDueDatePatch(updated: TodoItem, input: PatchInput): ItemsActionResult | null {
240
+ if (input.dueDate === null || input.dueDate === "") {
241
+ delete updated.dueDate;
242
+ return null;
243
+ }
244
+ if (input.dueDate === undefined) return null;
245
+ if (!isDueDate(input.dueDate)) {
246
+ return { kind: "error", status: 400, error: "dueDate must be YYYY-MM-DD" };
247
+ }
248
+ updated.dueDate = input.dueDate;
249
+ return null;
250
+ }
251
+
252
+ function applyStatusPatch(updated: TodoItem, target: TodoItem, items: TodoItem[], columns: StatusColumn[], input: PatchInput): ItemsActionResult | null {
253
+ if (typeof input.status !== "string" || input.status === target.status) {
254
+ return null;
255
+ }
256
+ const validStatusIds = new Set(columns.map((column) => column.id));
257
+ if (!validStatusIds.has(input.status)) {
258
+ return {
259
+ kind: "error",
260
+ status: 400,
261
+ error: `unknown status: ${input.status}`,
262
+ };
263
+ }
264
+ updated.status = input.status;
265
+ updated.order = nextOrder(items, input.status);
266
+ updated.completed = input.status === doneColumnId(columns);
267
+ return null;
268
+ }
269
+
270
+ // Explicit `completed` toggle without changing status: lets the user
271
+ // check / uncheck a card and have item move between the done column and
272
+ // a default open column the obvious way.
273
+ function applyCompletedPatch(updated: TodoItem, items: TodoItem[], columns: StatusColumn[], input: PatchInput): void {
274
+ if (typeof input.completed !== "boolean") return;
275
+ if (input.completed === updated.completed) return;
276
+ updated.completed = input.completed;
277
+ const targetStatus = input.completed ? doneColumnId(columns) : defaultStatusId(columns);
278
+ if (targetStatus !== updated.status) {
279
+ updated.status = targetStatus;
280
+ updated.order = nextOrder(items, targetStatus);
281
+ }
282
+ }
283
+
284
+ export function handlePatch(items: TodoItem[], columns: StatusColumn[], id: string, input: PatchInput): ItemsActionResult {
285
+ const target = items.find((i) => i.id === id);
286
+ if (!target) {
287
+ return { kind: "error", status: 404, error: `item not found: ${id}` };
288
+ }
289
+ const updated: TodoItem = { ...target };
290
+
291
+ // Each step short-circuits on validation failure. Order matters:
292
+ // status changes happen before completed-toggling so an explicit
293
+ // completed: true alongside a non-done status doesn't fight itself.
294
+ const steps: Array<() => ItemsActionResult | null | void> = [
295
+ () => applyTextPatch(updated, input),
296
+ () => applyNotePatch(updated, input),
297
+ () => applyLabelsPatch(updated, input),
298
+ () => applyPriorityPatch(updated, input),
299
+ () => applyDueDatePatch(updated, input),
300
+ () => applyStatusPatch(updated, target, items, columns, input),
301
+ () => applyCompletedPatch(updated, items, columns, input),
302
+ ];
303
+ for (const step of steps) {
304
+ const err = step();
305
+ if (err) return err;
306
+ }
307
+
308
+ const next = items.map((item) => (item.id === id ? updated : item));
309
+ return { kind: "success", items: next, item: updated };
310
+ }
311
+
312
+ // ── Move (drag & drop) ────────────────────────────────────────────
313
+
314
+ // Reorder + cross-column move in a single call. `position` is the
315
+ // 0-based index the item should occupy in its target column AFTER
316
+ // the move (with the moving item itself excluded from the count).
317
+ //
318
+ // We rebuild the entire target column's order field for simplicity:
319
+ // it's O(n) per column, which for a kanban with hundreds of items is
320
+ // negligible and makes the math obviously correct.
321
+ export interface MoveInput {
322
+ status?: string;
323
+ position?: number;
324
+ }
325
+
326
+ export function handleMove(items: TodoItem[], columns: StatusColumn[], id: string, input: MoveInput): ItemsActionResult {
327
+ const target = items.find((i) => i.id === id);
328
+ if (!target) {
329
+ return { kind: "error", status: 404, error: `item not found: ${id}` };
330
+ }
331
+ const validStatusIds = new Set(columns.map((column) => column.id));
332
+ const newStatus = input.status ?? target.status ?? defaultStatusId(columns);
333
+ if (!validStatusIds.has(newStatus)) {
334
+ return {
335
+ kind: "error",
336
+ status: 400,
337
+ error: `unknown status: ${newStatus}`,
338
+ };
339
+ }
340
+ const isDone = newStatus === doneColumnId(columns);
341
+ const updatedSelf: TodoItem = {
342
+ ...target,
343
+ status: newStatus,
344
+ completed: isDone,
345
+ };
346
+ // Re-collect the items in the target column with the moving item
347
+ // pulled out, then splice item back in at `position`.
348
+ const others = items.filter((item) => item.id !== id && item.status === newStatus).sort((left, right) => (left.order ?? 0) - (right.order ?? 0));
349
+ const insertAt = clampPosition(input.position, others.length);
350
+ const reordered = [...others];
351
+ reordered.splice(insertAt, 0, updatedSelf);
352
+ // Reassign order values 1000 / 2000 / 3000 ...
353
+ const reorderedById = new Map<string, number>();
354
+ reordered.forEach((item, i) => reorderedById.set(item.id, (i + 1) * ORDER_STEP));
355
+ const nextItems = items.map((item): TodoItem => {
356
+ const newOrder = reorderedById.get(item.id);
357
+ if (item.id === id) {
358
+ const out: TodoItem = {
359
+ ...updatedSelf,
360
+ order: newOrder ?? updatedSelf.order ?? ORDER_STEP,
361
+ };
362
+ return out;
363
+ }
364
+ if (newOrder !== undefined) return { ...item, order: newOrder };
365
+ return item;
366
+ });
367
+ const finalSelf = nextItems.find((item) => item.id === id)!;
368
+ return { kind: "success", items: nextItems, item: finalSelf };
369
+ }
370
+
371
+ function clampPosition(raw: number | undefined, max: number): number {
372
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return max;
373
+ if (raw < 0) return 0;
374
+ if (raw > max) return max;
375
+ return Math.floor(raw);
376
+ }
377
+
378
+ // ── Delete ────────────────────────────────────────────────────────
379
+
380
+ export function handleDeleteItem(items: TodoItem[], id: string): ItemsActionResult {
381
+ const target = items.find((i) => i.id === id);
382
+ if (!target) {
383
+ return { kind: "error", status: 404, error: `item not found: ${id}` };
384
+ }
385
+ return { kind: "success", items: items.filter((item) => item.id !== id) };
386
+ }