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.
- package/README.md +44 -0
- package/bin/mulmoclaude.js +202 -0
- package/bin/prepare-dist.js +93 -0
- package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +1 -0
- package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +5 -0
- package/client/assets/index-D8rhwXLq.js +4906 -0
- package/client/assets/index-KNLBjwuh.css +1 -0
- package/client/assets/index.es-D4YyL_Dg-BfRHLTZV.js +5 -0
- package/client/assets/material-icons-Dr0goTwe.woff +0 -0
- package/client/assets/material-icons-kAwBdRge.woff2 +0 -0
- package/client/assets/material-icons-outlined-BpWbwl2n.woff +0 -0
- package/client/assets/material-icons-outlined-DZhiGvEA.woff2 +0 -0
- package/client/assets/material-icons-round-BDlwx-sv.woff +0 -0
- package/client/assets/material-icons-round-DrirKXBx.woff2 +0 -0
- package/client/assets/material-icons-sharp-CH1KkVu7.woff +0 -0
- package/client/assets/material-icons-sharp-gidztirS.woff2 +0 -0
- package/client/assets/material-icons-two-tone-B7wz7mED.woff +0 -0
- package/client/assets/material-icons-two-tone-DuNIpaEj.woff2 +0 -0
- package/client/assets/mulmo_bw-ERmkSv0a.png +0 -0
- package/client/assets/purify.es-Fx1Nqyry-PeS5RUhs.js +2 -0
- package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +1 -0
- package/client/index.html +28 -0
- package/package.json +66 -0
- package/server/agent/attachmentConverter.ts +270 -0
- package/server/agent/config.ts +414 -0
- package/server/agent/index.ts +260 -0
- package/server/agent/mcp-server.ts +412 -0
- package/server/agent/mcp-tools/index.ts +63 -0
- package/server/agent/mcp-tools/x.ts +188 -0
- package/server/agent/plugin-names.ts +75 -0
- package/server/agent/prompt.ts +349 -0
- package/server/agent/resumeFailover.ts +129 -0
- package/server/agent/sandboxMounts.ts +329 -0
- package/server/agent/stream.ts +194 -0
- package/server/api/auth/bearerAuth.ts +61 -0
- package/server/api/auth/token.ts +98 -0
- package/server/api/csrfGuard.ts +85 -0
- package/server/api/routes/agent.ts +478 -0
- package/server/api/routes/chart.ts +98 -0
- package/server/api/routes/chat-index.ts +46 -0
- package/server/api/routes/config.ts +258 -0
- package/server/api/routes/dispatchResponse.ts +79 -0
- package/server/api/routes/files.ts +812 -0
- package/server/api/routes/html.ts +101 -0
- package/server/api/routes/image.ts +169 -0
- package/server/api/routes/mulmo-script.ts +712 -0
- package/server/api/routes/mulmoScriptValidate.ts +101 -0
- package/server/api/routes/notifications.ts +69 -0
- package/server/api/routes/pdf.ts +163 -0
- package/server/api/routes/plugins.ts +276 -0
- package/server/api/routes/presentHtml.ts +48 -0
- package/server/api/routes/roles.ts +125 -0
- package/server/api/routes/scheduler.ts +153 -0
- package/server/api/routes/schedulerHandlers.ts +151 -0
- package/server/api/routes/schedulerTasks.ts +163 -0
- package/server/api/routes/sessions.ts +294 -0
- package/server/api/routes/sessionsCursor.ts +59 -0
- package/server/api/routes/skills.ts +195 -0
- package/server/api/routes/sources.ts +540 -0
- package/server/api/routes/todos.ts +263 -0
- package/server/api/routes/todosColumnsHandlers.ts +347 -0
- package/server/api/routes/todosHandlers.ts +274 -0
- package/server/api/routes/todosItemsHandlers.ts +386 -0
- package/server/api/routes/wiki/pageIndex.ts +53 -0
- package/server/api/routes/wiki.ts +363 -0
- package/server/api/sandboxStatus.ts +64 -0
- package/server/events/notifications.ts +160 -0
- package/server/events/pub-sub/index.ts +45 -0
- package/server/events/relay-client.ts +288 -0
- package/server/events/scheduler-adapter.ts +302 -0
- package/server/events/session-store/index.ts +492 -0
- package/server/events/task-manager/index.ts +181 -0
- package/server/index.ts +572 -0
- package/server/system/config.ts +243 -0
- package/server/system/credentials.ts +220 -0
- package/server/system/docker.ts +97 -0
- package/server/system/env.ts +109 -0
- package/server/system/logger/config.ts +112 -0
- package/server/system/logger/formatters.ts +40 -0
- package/server/system/logger/index.ts +53 -0
- package/server/system/logger/rotation.ts +37 -0
- package/server/system/logger/sinks.ts +101 -0
- package/server/system/logger/types.ts +29 -0
- package/server/utils/date.ts +57 -0
- package/server/utils/errors.ts +7 -0
- package/server/utils/fetch.ts +27 -0
- package/server/utils/files/atomic.ts +125 -0
- package/server/utils/files/html-io.ts +20 -0
- package/server/utils/files/image-store.ts +66 -0
- package/server/utils/files/index.ts +45 -0
- package/server/utils/files/journal-io.ts +213 -0
- package/server/utils/files/json.ts +69 -0
- package/server/utils/files/markdown-store.ts +33 -0
- package/server/utils/files/naming.ts +50 -0
- package/server/utils/files/reference-dirs-io.ts +45 -0
- package/server/utils/files/roles-io.ts +45 -0
- package/server/utils/files/safe.ts +106 -0
- package/server/utils/files/scheduler-io.ts +20 -0
- package/server/utils/files/scheduler-overrides-io.ts +64 -0
- package/server/utils/files/session-io.ts +136 -0
- package/server/utils/files/spreadsheet-store.ts +63 -0
- package/server/utils/files/todos-io.ts +29 -0
- package/server/utils/files/user-tasks-io.ts +25 -0
- package/server/utils/files/workspace-io.ts +221 -0
- package/server/utils/gemini.ts +59 -0
- package/server/utils/gitignore.ts +69 -0
- package/server/utils/http.ts +15 -0
- package/server/utils/httpError.ts +61 -0
- package/server/utils/id.ts +16 -0
- package/server/utils/json.ts +83 -0
- package/server/utils/logBackgroundError.ts +22 -0
- package/server/utils/markdown.ts +82 -0
- package/server/utils/request.ts +29 -0
- package/server/utils/slug.ts +50 -0
- package/server/utils/spawn.ts +62 -0
- package/server/utils/time.ts +34 -0
- package/server/utils/types.ts +47 -0
- package/server/workspace/chat-index/index.ts +153 -0
- package/server/workspace/chat-index/indexer.ts +209 -0
- package/server/workspace/chat-index/paths.ts +34 -0
- package/server/workspace/chat-index/summarizer.ts +247 -0
- package/server/workspace/chat-index/types.ts +38 -0
- package/server/workspace/custom-dirs.ts +220 -0
- package/server/workspace/helps/business.md +104 -0
- package/server/workspace/helps/github.md +23 -0
- package/server/workspace/helps/index.md +60 -0
- package/server/workspace/helps/mulmoscript.md +249 -0
- package/server/workspace/helps/sandbox.md +90 -0
- package/server/workspace/helps/spreadsheet.md +43 -0
- package/server/workspace/helps/telegram.md +135 -0
- package/server/workspace/helps/wiki.md +131 -0
- package/server/workspace/journal/archivist.ts +386 -0
- package/server/workspace/journal/dailyPass.ts +743 -0
- package/server/workspace/journal/diff.ts +71 -0
- package/server/workspace/journal/index.ts +185 -0
- package/server/workspace/journal/indexFile.ts +136 -0
- package/server/workspace/journal/linkRewrite.ts +4 -0
- package/server/workspace/journal/memoryExtractor.ts +130 -0
- package/server/workspace/journal/optimizationPass.ts +160 -0
- package/server/workspace/journal/paths.ts +76 -0
- package/server/workspace/journal/state.ts +125 -0
- package/server/workspace/paths.ts +158 -0
- package/server/workspace/reference-dirs.ts +252 -0
- package/server/workspace/roles.ts +37 -0
- package/server/workspace/skills/discovery.ts +125 -0
- package/server/workspace/skills/index.ts +10 -0
- package/server/workspace/skills/parser.ts +144 -0
- package/server/workspace/skills/paths.ts +41 -0
- package/server/workspace/skills/scheduler.ts +149 -0
- package/server/workspace/skills/types.ts +30 -0
- package/server/workspace/skills/user-tasks.ts +257 -0
- package/server/workspace/skills/writer.ts +189 -0
- package/server/workspace/sources/arxivDiscovery.ts +182 -0
- package/server/workspace/sources/classifier.ts +268 -0
- package/server/workspace/sources/fetchers/arxiv.ts +170 -0
- package/server/workspace/sources/fetchers/github.ts +106 -0
- package/server/workspace/sources/fetchers/githubIssues.ts +208 -0
- package/server/workspace/sources/fetchers/githubReleases.ts +186 -0
- package/server/workspace/sources/fetchers/index.ts +71 -0
- package/server/workspace/sources/fetchers/registerAll.ts +15 -0
- package/server/workspace/sources/fetchers/rss.ts +141 -0
- package/server/workspace/sources/fetchers/rssParser.ts +295 -0
- package/server/workspace/sources/httpFetcher.ts +230 -0
- package/server/workspace/sources/interests.ts +120 -0
- package/server/workspace/sources/paths.ts +110 -0
- package/server/workspace/sources/pipeline/dedup.ts +60 -0
- package/server/workspace/sources/pipeline/fetch.ts +136 -0
- package/server/workspace/sources/pipeline/index.ts +249 -0
- package/server/workspace/sources/pipeline/notify.ts +72 -0
- package/server/workspace/sources/pipeline/plan.ts +66 -0
- package/server/workspace/sources/pipeline/summarize.ts +189 -0
- package/server/workspace/sources/pipeline/write.ts +185 -0
- package/server/workspace/sources/rateLimiter.ts +148 -0
- package/server/workspace/sources/registry.ts +326 -0
- package/server/workspace/sources/robots.ts +271 -0
- package/server/workspace/sources/sourceState.ts +135 -0
- package/server/workspace/sources/taxonomy.ts +74 -0
- package/server/workspace/sources/types.ts +144 -0
- package/server/workspace/sources/urls.ts +112 -0
- package/server/workspace/tool-trace/classify.ts +114 -0
- package/server/workspace/tool-trace/index.ts +250 -0
- package/server/workspace/tool-trace/writeSearch.ts +98 -0
- package/server/workspace/wiki-backlinks/index.ts +107 -0
- package/server/workspace/wiki-backlinks/sessionBacklinks.ts +144 -0
- package/server/workspace/workspace.ts +66 -0
- package/src/App.vue +720 -0
- package/src/assets/mulmo_bw.png +0 -0
- package/src/components/CanvasViewToggle.vue +27 -0
- package/src/components/ChatAttachmentPreview.vue +45 -0
- package/src/components/ChatImagePreview.vue +17 -0
- package/src/components/ChatInput.vue +208 -0
- package/src/components/FileContentHeader.vue +49 -0
- package/src/components/FileContentRenderer.vue +162 -0
- package/src/components/FileTree.vue +115 -0
- package/src/components/FileTreePane.vue +85 -0
- package/src/components/FilesView.vue +206 -0
- package/src/components/LockStatusPopup.vue +111 -0
- package/src/components/NotificationBell.vue +131 -0
- package/src/components/NotificationToast.vue +72 -0
- package/src/components/PluginLauncher.vue +138 -0
- package/src/components/RightSidebar.vue +113 -0
- package/src/components/RoleSelector.vue +64 -0
- package/src/components/SessionHistoryPanel.vue +176 -0
- package/src/components/SessionTabBar.vue +81 -0
- package/src/components/SettingsMcpTab.vue +350 -0
- package/src/components/SettingsModal.vue +275 -0
- package/src/components/SettingsReferenceDirsTab.vue +173 -0
- package/src/components/SettingsWorkspaceDirsTab.vue +174 -0
- package/src/components/SidebarHeader.vue +69 -0
- package/src/components/StackView.vue +360 -0
- package/src/components/SuggestionsPanel.vue +65 -0
- package/src/components/TodoExplorer.vue +358 -0
- package/src/components/ToolResultsPanel.vue +77 -0
- package/src/components/todo/TodoAddDialog.vue +131 -0
- package/src/components/todo/TodoEditDialog.vue +47 -0
- package/src/components/todo/TodoEditPanel.vue +113 -0
- package/src/components/todo/TodoKanbanView.vue +249 -0
- package/src/components/todo/TodoListView.vue +79 -0
- package/src/components/todo/TodoTableView.vue +177 -0
- package/src/composables/useActiveSession.ts +40 -0
- package/src/composables/useAppApi.ts +45 -0
- package/src/composables/useCanvasViewMode.ts +121 -0
- package/src/composables/useChatScroll.ts +47 -0
- package/src/composables/useClickOutside.ts +26 -0
- package/src/composables/useClipboardCopy.ts +44 -0
- package/src/composables/useContentDisplay.ts +52 -0
- package/src/composables/useDebugBeat.ts +23 -0
- package/src/composables/useDynamicFavicon.ts +115 -0
- package/src/composables/useEventListeners.ts +42 -0
- package/src/composables/useExpandedDirs.ts +64 -0
- package/src/composables/useFaviconState.ts +30 -0
- package/src/composables/useFileSelection.ts +115 -0
- package/src/composables/useFileSortMode.ts +24 -0
- package/src/composables/useFileTree.ts +85 -0
- package/src/composables/useFreshPluginData.ts +89 -0
- package/src/composables/useHealth.ts +38 -0
- package/src/composables/useImeAwareEnter.ts +57 -0
- package/src/composables/useKeyNavigation.ts +60 -0
- package/src/composables/useMarkdownLinkHandler.ts +46 -0
- package/src/composables/useMarkdownMode.ts +17 -0
- package/src/composables/useMcpTools.ts +71 -0
- package/src/composables/useMergedSessions.ts +27 -0
- package/src/composables/useNotifications.ts +90 -0
- package/src/composables/usePdfDownload.ts +60 -0
- package/src/composables/usePendingCalls.ts +77 -0
- package/src/composables/usePubSub.ts +85 -0
- package/src/composables/useRightSidebar.ts +23 -0
- package/src/composables/useRoles.ts +34 -0
- package/src/composables/useSandboxStatus.ts +67 -0
- package/src/composables/useSelectedResult.ts +49 -0
- package/src/composables/useSessionDerived.ts +51 -0
- package/src/composables/useSessionHistory.ts +81 -0
- package/src/composables/useSessionSync.ts +57 -0
- package/src/composables/useViewLayout.ts +55 -0
- package/src/config/apiRoutes.ts +173 -0
- package/src/config/pubsubChannels.ts +45 -0
- package/src/config/roles.ts +335 -0
- package/src/config/schedulerActions.ts +25 -0
- package/src/config/toolNames.ts +71 -0
- package/src/config/workspacePaths.ts +24 -0
- package/src/index.css +107 -0
- package/src/main.ts +25 -0
- package/src/plugins/canvas/Preview.vue +13 -0
- package/src/plugins/canvas/View.vue +333 -0
- package/src/plugins/canvas/definition.ts +38 -0
- package/src/plugins/canvas/index.ts +36 -0
- package/src/plugins/chart/Preview.vue +49 -0
- package/src/plugins/chart/View.vue +143 -0
- package/src/plugins/chart/definition.ts +58 -0
- package/src/plugins/chart/index.ts +52 -0
- package/src/plugins/editImage/Preview.vue +13 -0
- package/src/plugins/editImage/View.vue +13 -0
- package/src/plugins/editImage/definition.ts +27 -0
- package/src/plugins/editImage/index.ts +36 -0
- package/src/plugins/generateImage/Preview.vue +13 -0
- package/src/plugins/generateImage/View.vue +33 -0
- package/src/plugins/generateImage/definition.ts +32 -0
- package/src/plugins/generateImage/index.ts +56 -0
- package/src/plugins/manageRoles/Preview.vue +49 -0
- package/src/plugins/manageRoles/View.vue +525 -0
- package/src/plugins/manageRoles/definition.ts +43 -0
- package/src/plugins/manageRoles/index.ts +47 -0
- package/src/plugins/manageSkills/Preview.vue +21 -0
- package/src/plugins/manageSkills/View.vue +321 -0
- package/src/plugins/manageSkills/definition.ts +49 -0
- package/src/plugins/manageSkills/index.ts +49 -0
- package/src/plugins/manageSource/Preview.vue +33 -0
- package/src/plugins/manageSource/View.vue +697 -0
- package/src/plugins/manageSource/definition.ts +63 -0
- package/src/plugins/manageSource/index.ts +66 -0
- package/src/plugins/markdown/Preview.vue +77 -0
- package/src/plugins/markdown/View.vue +476 -0
- package/src/plugins/markdown/definition.ts +50 -0
- package/src/plugins/markdown/index.ts +36 -0
- package/src/plugins/presentHtml/Preview.vue +25 -0
- package/src/plugins/presentHtml/View.vue +52 -0
- package/src/plugins/presentHtml/definition.ts +27 -0
- package/src/plugins/presentHtml/helpers.ts +72 -0
- package/src/plugins/presentHtml/index.ts +41 -0
- package/src/plugins/presentMulmoScript/Preview.vue +23 -0
- package/src/plugins/presentMulmoScript/View.vue +1166 -0
- package/src/plugins/presentMulmoScript/definition.ts +95 -0
- package/src/plugins/presentMulmoScript/helpers.ts +162 -0
- package/src/plugins/presentMulmoScript/index.ts +40 -0
- package/src/plugins/scheduler/Preview.vue +67 -0
- package/src/plugins/scheduler/TasksTab.vue +205 -0
- package/src/plugins/scheduler/View.vue +565 -0
- package/src/plugins/scheduler/definition.ts +57 -0
- package/src/plugins/scheduler/index.ts +45 -0
- package/src/plugins/scheduler/viewModes.ts +26 -0
- package/src/plugins/spreadsheet/Preview.vue +29 -0
- package/src/plugins/spreadsheet/View.vue +997 -0
- package/src/plugins/spreadsheet/cellHighlights.ts +79 -0
- package/src/plugins/spreadsheet/definition.ts +121 -0
- package/src/plugins/spreadsheet/engine/calculator.ts +459 -0
- package/src/plugins/spreadsheet/engine/cellBuilder.ts +81 -0
- package/src/plugins/spreadsheet/engine/date-parser.ts +220 -0
- package/src/plugins/spreadsheet/engine/date-utils.ts +56 -0
- package/src/plugins/spreadsheet/engine/engine.ts +176 -0
- package/src/plugins/spreadsheet/engine/evaluator.ts +390 -0
- package/src/plugins/spreadsheet/engine/formatter.ts +172 -0
- package/src/plugins/spreadsheet/engine/formulaRefs.ts +101 -0
- package/src/plugins/spreadsheet/engine/functions/date.ts +299 -0
- package/src/plugins/spreadsheet/engine/functions/financial.ts +387 -0
- package/src/plugins/spreadsheet/engine/functions/index.ts +16 -0
- package/src/plugins/spreadsheet/engine/functions/logical.ts +262 -0
- package/src/plugins/spreadsheet/engine/functions/lookup.ts +400 -0
- package/src/plugins/spreadsheet/engine/functions/mathematical.ts +297 -0
- package/src/plugins/spreadsheet/engine/functions/statistical.ts +338 -0
- package/src/plugins/spreadsheet/engine/functions/text.ts +389 -0
- package/src/plugins/spreadsheet/engine/index.ts +27 -0
- package/src/plugins/spreadsheet/engine/jsonCellLocator.ts +111 -0
- package/src/plugins/spreadsheet/engine/parser.ts +143 -0
- package/src/plugins/spreadsheet/engine/registry.ts +150 -0
- package/src/plugins/spreadsheet/engine/responseDecoder.ts +67 -0
- package/src/plugins/spreadsheet/engine/types.ts +64 -0
- package/src/plugins/spreadsheet/index.ts +36 -0
- package/src/plugins/textResponse/Preview.vue +94 -0
- package/src/plugins/textResponse/View.vue +503 -0
- package/src/plugins/textResponse/definition.ts +34 -0
- package/src/plugins/textResponse/index.ts +27 -0
- package/src/plugins/textResponse/plugin.ts +29 -0
- package/src/plugins/textResponse/samples.ts +97 -0
- package/src/plugins/textResponse/types.ts +11 -0
- package/src/plugins/todo/Preview.vue +63 -0
- package/src/plugins/todo/View.vue +364 -0
- package/src/plugins/todo/composables/useTodos.ts +177 -0
- package/src/plugins/todo/definition.ts +45 -0
- package/src/plugins/todo/index.ts +61 -0
- package/src/plugins/todo/labels.ts +163 -0
- package/src/plugins/todo/priority.ts +98 -0
- package/src/plugins/todo/viewModes.ts +19 -0
- package/src/plugins/ui-image/ImagePreview.vue +23 -0
- package/src/plugins/ui-image/ImageView.vue +34 -0
- package/src/plugins/ui-image/index.ts +3 -0
- package/src/plugins/ui-image/types.ts +4 -0
- package/src/plugins/wiki/Preview.vue +65 -0
- package/src/plugins/wiki/View.vue +342 -0
- package/src/plugins/wiki/definition.ts +25 -0
- package/src/plugins/wiki/helpers.ts +59 -0
- package/src/plugins/wiki/index.ts +52 -0
- package/src/router/guards.ts +61 -0
- package/src/router/index.ts +50 -0
- package/src/tools/index.ts +52 -0
- package/src/tools/types.ts +27 -0
- package/src/types/events.ts +16 -0
- package/src/types/fileTree.ts +13 -0
- package/src/types/notification.ts +67 -0
- package/src/types/session.ts +116 -0
- package/src/types/sse.ts +90 -0
- package/src/types/toolCallHistory.ts +13 -0
- package/src/utils/agent/eventDispatch.ts +74 -0
- package/src/utils/agent/request.ts +55 -0
- package/src/utils/agent/toolCalls.ts +62 -0
- package/src/utils/api.ts +218 -0
- package/src/utils/canvas/viewMode.ts +46 -0
- package/src/utils/dom/authTokenMeta.ts +20 -0
- package/src/utils/dom/clickOutside.ts +11 -0
- package/src/utils/dom/externalLink.ts +57 -0
- package/src/utils/dom/scrollable.ts +24 -0
- package/src/utils/errors.ts +11 -0
- package/src/utils/files/expandedDirs.ts +25 -0
- package/src/utils/files/filename.ts +12 -0
- package/src/utils/files/sortChildren.ts +20 -0
- package/src/utils/filesPreview/schedulerPreview.ts +38 -0
- package/src/utils/filesPreview/todoPreview.ts +40 -0
- package/src/utils/format/date.ts +85 -0
- package/src/utils/format/frontmatter.ts +80 -0
- package/src/utils/format/jsonSyntax.ts +109 -0
- package/src/utils/html/previewCsp.ts +65 -0
- package/src/utils/image/resolve.ts +8 -0
- package/src/utils/image/rewriteMarkdownImageRefs.ts +182 -0
- package/src/utils/markdown/extractFirstH1.ts +39 -0
- package/src/utils/notification/dispatch.ts +22 -0
- package/src/utils/path/relativeLink.ts +130 -0
- package/src/utils/role/icon.ts +20 -0
- package/src/utils/role/merge.ts +10 -0
- package/src/utils/role/plugins.ts +12 -0
- package/src/utils/session/mergeSessions.ts +103 -0
- package/src/utils/session/seedRoleDefault.ts +35 -0
- package/src/utils/session/sessionEntries.ts +121 -0
- package/src/utils/session/sessionFactory.ts +22 -0
- package/src/utils/session/sessionHelpers.ts +99 -0
- package/src/utils/tools/dedup.ts +17 -0
- package/src/utils/tools/mcp.ts +33 -0
- package/src/utils/tools/pendingCalls.ts +16 -0
- package/src/utils/tools/result.ts +40 -0
- package/src/utils/types.ts +44 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { Router, Request, Response } from "express";
|
|
2
|
+
import {
|
|
3
|
+
loadTodos as loadTodosRaw,
|
|
4
|
+
saveTodos as saveTodosRaw,
|
|
5
|
+
loadColumns as loadColumnsRaw,
|
|
6
|
+
saveColumns as saveColumnsRaw,
|
|
7
|
+
} from "../../utils/files/todos-io.js";
|
|
8
|
+
import { dispatchTodos, type TodosActionInput } from "./todosHandlers.js";
|
|
9
|
+
import {
|
|
10
|
+
type StatusColumn,
|
|
11
|
+
DEFAULT_COLUMNS,
|
|
12
|
+
handleAddColumn,
|
|
13
|
+
handleDeleteColumn,
|
|
14
|
+
handlePatchColumn,
|
|
15
|
+
handleReorderColumns,
|
|
16
|
+
normalizeColumns,
|
|
17
|
+
} from "./todosColumnsHandlers.js";
|
|
18
|
+
import {
|
|
19
|
+
handleCreate,
|
|
20
|
+
handleDeleteItem,
|
|
21
|
+
handleMove,
|
|
22
|
+
handlePatch,
|
|
23
|
+
migrateItems,
|
|
24
|
+
type CreateInput,
|
|
25
|
+
type MoveInput,
|
|
26
|
+
type PatchInput,
|
|
27
|
+
} from "./todosItemsHandlers.js";
|
|
28
|
+
import { respondWithDispatchResult, type DispatchSuccessResponse, type DispatchErrorResponse } from "./dispatchResponse.js";
|
|
29
|
+
|
|
30
|
+
import { API_ROUTES } from "../../../src/config/apiRoutes.js";
|
|
31
|
+
|
|
32
|
+
const router = Router();
|
|
33
|
+
|
|
34
|
+
export type TodoPriority = "low" | "medium" | "high" | "urgent";
|
|
35
|
+
|
|
36
|
+
export interface TodoItem {
|
|
37
|
+
id: string;
|
|
38
|
+
text: string;
|
|
39
|
+
note?: string;
|
|
40
|
+
labels?: string[];
|
|
41
|
+
completed: boolean;
|
|
42
|
+
createdAt: number;
|
|
43
|
+
// ── Added for the file-explorer kanban view ──
|
|
44
|
+
// status: id of a column from columns.json. Optional on the wire so
|
|
45
|
+
// legacy items load cleanly; migrateTodos() backfills it on read.
|
|
46
|
+
status?: string;
|
|
47
|
+
priority?: TodoPriority;
|
|
48
|
+
dueDate?: string; // ISO YYYY-MM-DD
|
|
49
|
+
order?: number; // sort key within the same status column
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function loadColumns(): StatusColumn[] {
|
|
53
|
+
return normalizeColumns(loadColumnsRaw<unknown>(DEFAULT_COLUMNS));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function saveColumns(columns: StatusColumn[]): void {
|
|
57
|
+
saveColumnsRaw(columns);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function loadTodos(): TodoItem[] {
|
|
61
|
+
const raw = loadTodosRaw<TodoItem[]>([]);
|
|
62
|
+
const columns = loadColumns();
|
|
63
|
+
return migrateItems(raw, columns);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function saveTodos(items: TodoItem[]): void {
|
|
67
|
+
saveTodosRaw(items);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── GET /api/todos ───────────────────────────────────────────────
|
|
71
|
+
//
|
|
72
|
+
// Returns the migrated items + the current status columns. The
|
|
73
|
+
// columns field is new; existing chat-side consumers only read
|
|
74
|
+
// `data.items` so adding a sibling key is non-breaking.
|
|
75
|
+
|
|
76
|
+
interface TodosListResponse {
|
|
77
|
+
data: { items: TodoItem[]; columns: StatusColumn[] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
router.get(API_ROUTES.todos.list, (_req: Request, res: Response<TodosListResponse>) => {
|
|
81
|
+
res.json({ data: { items: loadTodos(), columns: loadColumns() } });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── POST /api/todos (legacy MCP action route) ────────────────────
|
|
85
|
+
//
|
|
86
|
+
// Uses the shared dispatcher response plumbing introduced in #145.
|
|
87
|
+
// The legacy MCP `manageTodoList` tool is the only consumer of this
|
|
88
|
+
// route — the file-explorer TodoExplorer calls the new id-based REST
|
|
89
|
+
// routes below instead — so the response shape stays the simple
|
|
90
|
+
// `{ data: { items } }` form. Columns aren't included here on purpose:
|
|
91
|
+
// the chat-side View.vue only reads `data.items`, and the explorer
|
|
92
|
+
// loads columns via GET /api/todos.
|
|
93
|
+
|
|
94
|
+
interface TodoBody extends TodosActionInput {
|
|
95
|
+
action: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Actions whose handlers may mutate state. "show" / "list_labels"
|
|
99
|
+
// are read-only views; persisting their result would be a no-op.
|
|
100
|
+
const READ_ONLY_ACTIONS = new Set(["show", "list_labels"]);
|
|
101
|
+
|
|
102
|
+
router.post(API_ROUTES.todos.dispatch, (req: Request<object, unknown, TodoBody>, res: Response<DispatchSuccessResponse<TodoItem> | DispatchErrorResponse>) => {
|
|
103
|
+
const { action, ...input } = req.body;
|
|
104
|
+
const items = loadTodos();
|
|
105
|
+
const result = dispatchTodos(action, items, input);
|
|
106
|
+
respondWithDispatchResult(res, result, {
|
|
107
|
+
shouldPersist: !READ_ONLY_ACTIONS.has(action),
|
|
108
|
+
instructions: "Display the updated todo list to the user.",
|
|
109
|
+
persist: saveTodos,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── New REST routes for the file-explorer todo view ──────────────
|
|
114
|
+
//
|
|
115
|
+
// These are id-based and used exclusively by the web UI. They live
|
|
116
|
+
// alongside the legacy MCP action route so the LLM-facing contract
|
|
117
|
+
// stays unchanged.
|
|
118
|
+
|
|
119
|
+
interface ItemResponse {
|
|
120
|
+
data: { items: TodoItem[]; columns: StatusColumn[] };
|
|
121
|
+
item?: TodoItem;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface ItemIdParams {
|
|
125
|
+
id: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface ColumnIdParams {
|
|
129
|
+
id: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// POST /api/todos/items — create a new todo
|
|
133
|
+
router.post(API_ROUTES.todos.items, (req: Request<object, unknown, CreateInput>, res: Response<ItemResponse | DispatchErrorResponse>) => {
|
|
134
|
+
const items = loadTodos();
|
|
135
|
+
const columns = loadColumns();
|
|
136
|
+
const result = handleCreate(items, columns, req.body);
|
|
137
|
+
if (result.kind === "error") {
|
|
138
|
+
res.status(result.status).json({ error: result.error });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
saveTodos(result.items);
|
|
142
|
+
res.json({
|
|
143
|
+
data: { items: result.items, columns },
|
|
144
|
+
...(result.item && { item: result.item }),
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// PATCH /api/todos/items/:id — partial update
|
|
149
|
+
router.patch(API_ROUTES.todos.item, (req: Request<ItemIdParams, unknown, PatchInput>, res: Response<ItemResponse | DispatchErrorResponse>) => {
|
|
150
|
+
const items = loadTodos();
|
|
151
|
+
const columns = loadColumns();
|
|
152
|
+
const result = handlePatch(items, columns, req.params.id, req.body);
|
|
153
|
+
if (result.kind === "error") {
|
|
154
|
+
res.status(result.status).json({ error: result.error });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
saveTodos(result.items);
|
|
158
|
+
res.json({
|
|
159
|
+
data: { items: result.items, columns },
|
|
160
|
+
...(result.item && { item: result.item }),
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// POST /api/todos/items/:id/move — drag & drop persistence
|
|
165
|
+
router.post(API_ROUTES.todos.itemMove, (req: Request<ItemIdParams, unknown, MoveInput>, res: Response<ItemResponse | DispatchErrorResponse>) => {
|
|
166
|
+
const items = loadTodos();
|
|
167
|
+
const columns = loadColumns();
|
|
168
|
+
const result = handleMove(items, columns, req.params.id, req.body);
|
|
169
|
+
if (result.kind === "error") {
|
|
170
|
+
res.status(result.status).json({ error: result.error });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
saveTodos(result.items);
|
|
174
|
+
res.json({
|
|
175
|
+
data: { items: result.items, columns },
|
|
176
|
+
...(result.item && { item: result.item }),
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// DELETE /api/todos/items/:id
|
|
181
|
+
router.delete(API_ROUTES.todos.item, (req: Request<ItemIdParams>, res: Response<ItemResponse | DispatchErrorResponse>) => {
|
|
182
|
+
const items = loadTodos();
|
|
183
|
+
const columns = loadColumns();
|
|
184
|
+
const result = handleDeleteItem(items, req.params.id);
|
|
185
|
+
if (result.kind === "error") {
|
|
186
|
+
res.status(result.status).json({ error: result.error });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
saveTodos(result.items);
|
|
190
|
+
res.json({ data: { items: result.items, columns } });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ── Columns ──────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
interface ColumnsResponse {
|
|
196
|
+
data: { items: TodoItem[]; columns: StatusColumn[] };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
interface AddColumnBody {
|
|
200
|
+
label?: string;
|
|
201
|
+
isDone?: boolean;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface PatchColumnBody {
|
|
205
|
+
label?: string;
|
|
206
|
+
isDone?: boolean;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
interface ReorderColumnsBody {
|
|
210
|
+
ids?: string[];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
router.get(API_ROUTES.todos.columns, (_req: Request, res: Response<ColumnsResponse>) => {
|
|
214
|
+
res.json({ data: { items: loadTodos(), columns: loadColumns() } });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
router.post(API_ROUTES.todos.columns, (req: Request<object, unknown, AddColumnBody>, res: Response<ColumnsResponse | DispatchErrorResponse>) => {
|
|
218
|
+
const items = loadTodos();
|
|
219
|
+
const result = handleAddColumn(loadColumns(), items, req.body);
|
|
220
|
+
if (result.kind === "error") {
|
|
221
|
+
res.status(result.status).json({ error: result.error });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
saveColumns(result.columns);
|
|
225
|
+
if (result.items) saveTodos(result.items);
|
|
226
|
+
res.json({ data: { items: loadTodos(), columns: result.columns } });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
router.patch(API_ROUTES.todos.column, (req: Request<ColumnIdParams, unknown, PatchColumnBody>, res: Response<ColumnsResponse | DispatchErrorResponse>) => {
|
|
230
|
+
const items = loadTodos();
|
|
231
|
+
const result = handlePatchColumn(loadColumns(), req.params.id, req.body, items);
|
|
232
|
+
if (result.kind === "error") {
|
|
233
|
+
res.status(result.status).json({ error: result.error });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
saveColumns(result.columns);
|
|
237
|
+
if (result.items) saveTodos(result.items);
|
|
238
|
+
res.json({ data: { items: loadTodos(), columns: result.columns } });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
router.delete(API_ROUTES.todos.column, (req: Request<ColumnIdParams>, res: Response<ColumnsResponse | DispatchErrorResponse>) => {
|
|
242
|
+
const items = loadTodos();
|
|
243
|
+
const result = handleDeleteColumn(loadColumns(), req.params.id, items);
|
|
244
|
+
if (result.kind === "error") {
|
|
245
|
+
res.status(result.status).json({ error: result.error });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
saveColumns(result.columns);
|
|
249
|
+
if (result.items) saveTodos(result.items);
|
|
250
|
+
res.json({ data: { items: loadTodos(), columns: result.columns } });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
router.put(API_ROUTES.todos.columnsOrder, (req: Request<object, unknown, ReorderColumnsBody>, res: Response<ColumnsResponse | DispatchErrorResponse>) => {
|
|
254
|
+
const result = handleReorderColumns(loadColumns(), req.body.ids ?? []);
|
|
255
|
+
if (result.kind === "error") {
|
|
256
|
+
res.status(result.status).json({ error: result.error });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
saveColumns(result.columns);
|
|
260
|
+
res.json({ data: { items: loadTodos(), columns: result.columns } });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
export default router;
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Pure handlers for status columns used by the file-explorer todo
|
|
2
|
+
// view. Same shape as todosHandlers / schedulerHandlers: each function
|
|
3
|
+
// takes the current state + an input record and returns either an
|
|
4
|
+
// error or the next state. The Express route is responsible for
|
|
5
|
+
// loading / saving the underlying JSON files.
|
|
6
|
+
//
|
|
7
|
+
// Storage layout:
|
|
8
|
+
// workspace/todos/columns.json ← StatusColumn[]
|
|
9
|
+
//
|
|
10
|
+
// At least one column must always carry `isDone: true` so completed
|
|
11
|
+
// items have somewhere to live and the legacy `completed` boolean has
|
|
12
|
+
// something to map to.
|
|
13
|
+
|
|
14
|
+
import { hasNonAscii, hashSlug } from "../../utils/slug.js";
|
|
15
|
+
import { isRecord } from "../../utils/types.js";
|
|
16
|
+
import type { TodoItem } from "./todos.js";
|
|
17
|
+
|
|
18
|
+
export interface StatusColumn {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
// True for the column whose items are considered "completed".
|
|
22
|
+
// Exactly one column should have isDone: true at any given time;
|
|
23
|
+
// remove_column / patch_column rules enforce this.
|
|
24
|
+
isDone?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_COLUMNS: StatusColumn[] = [
|
|
28
|
+
{ id: "backlog", label: "Backlog" },
|
|
29
|
+
{ id: "todo", label: "Todo" },
|
|
30
|
+
{ id: "in_progress", label: "In Progress" },
|
|
31
|
+
{ id: "done", label: "Done", isDone: true },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// ── Result types ──────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export type ColumnsActionResult =
|
|
37
|
+
| { kind: "error"; status: number; error: string }
|
|
38
|
+
| {
|
|
39
|
+
kind: "success";
|
|
40
|
+
columns: StatusColumn[];
|
|
41
|
+
// Some operations also need to mutate items (e.g. removing a
|
|
42
|
+
// column reassigns its items to another column). When set, the
|
|
43
|
+
// route persists this items array as well.
|
|
44
|
+
items?: TodoItem[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ── id slug generation ────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
// Convert a free-text label into a URL-safe id. Lowercased ASCII
|
|
50
|
+
// letters/numbers/underscore only; everything else collapses to "_".
|
|
51
|
+
// Non-ASCII labels (e.g. Japanese) get a deterministic sha256-based
|
|
52
|
+
// id so distinct labels never collapse to the same fallback "column".
|
|
53
|
+
function slugify(label: string): string {
|
|
54
|
+
let slug = label
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.trim()
|
|
57
|
+
.replace(/[^a-z0-9]+/g, "_");
|
|
58
|
+
let start = 0;
|
|
59
|
+
let end = slug.length;
|
|
60
|
+
while (start < end && slug.charCodeAt(start) === 95) start++;
|
|
61
|
+
while (end > start && slug.charCodeAt(end - 1) === 95) end--;
|
|
62
|
+
slug = slug.slice(start, end);
|
|
63
|
+
|
|
64
|
+
if (!hasNonAscii(label)) return slug.length > 0 ? slug : "column";
|
|
65
|
+
|
|
66
|
+
const hash = hashSlug(label.trim());
|
|
67
|
+
if (slug.length >= 3) return `${slug}_${hash}`;
|
|
68
|
+
return hash;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Pick an id that doesn't collide with `existingIds`. Tries the bare
|
|
72
|
+
// slug first, then `_2`, `_3`, ... until something is free.
|
|
73
|
+
function uniqueId(base: string, existingIds: ReadonlySet<string>): string {
|
|
74
|
+
if (!existingIds.has(base)) return base;
|
|
75
|
+
let suffix = 2;
|
|
76
|
+
while (existingIds.has(`${base}_${suffix}`)) suffix++;
|
|
77
|
+
return `${base}_${suffix}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Validation helpers ────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function findColumn(columns: StatusColumn[], id: string): StatusColumn | undefined {
|
|
83
|
+
return columns.find((column) => column.id === id);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ensureColumnsValid(columns: StatusColumn[]): StatusColumn[] {
|
|
87
|
+
// Guarantee invariants when reading: at least one column, exactly
|
|
88
|
+
// one isDone column, ids are unique. If anything is off, fall back
|
|
89
|
+
// to DEFAULT_COLUMNS rather than try to repair partial state.
|
|
90
|
+
if (columns.length === 0) return [...DEFAULT_COLUMNS];
|
|
91
|
+
const seen = new Set<string>();
|
|
92
|
+
for (const column of columns) {
|
|
93
|
+
if (seen.has(column.id)) return [...DEFAULT_COLUMNS];
|
|
94
|
+
seen.add(column.id);
|
|
95
|
+
}
|
|
96
|
+
const doneCount = columns.filter((column) => column.isDone).length;
|
|
97
|
+
if (doneCount === 0) {
|
|
98
|
+
// Promote the last column to done so the invariant holds.
|
|
99
|
+
const fixed = columns.map((column, i) => (i === columns.length - 1 ? { ...column, isDone: true } : column));
|
|
100
|
+
return fixed;
|
|
101
|
+
}
|
|
102
|
+
if (doneCount > 1) {
|
|
103
|
+
// Keep only the first done flag.
|
|
104
|
+
let kept = false;
|
|
105
|
+
return columns.map((column) => {
|
|
106
|
+
if (!column.isDone) return column;
|
|
107
|
+
if (kept) {
|
|
108
|
+
const next: StatusColumn = { id: column.id, label: column.label };
|
|
109
|
+
return next;
|
|
110
|
+
}
|
|
111
|
+
kept = true;
|
|
112
|
+
return column;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return columns;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Public: load-time normaliser. Use this when parsing columns.json so
|
|
119
|
+
// the rest of the system never has to think about invalid shapes.
|
|
120
|
+
export function normalizeColumns(raw: unknown): StatusColumn[] {
|
|
121
|
+
if (!Array.isArray(raw)) return [...DEFAULT_COLUMNS];
|
|
122
|
+
const cleaned: StatusColumn[] = [];
|
|
123
|
+
for (const entry of raw) {
|
|
124
|
+
if (!isRecord(entry)) continue;
|
|
125
|
+
if (typeof entry["id"] !== "string" || typeof entry["label"] !== "string") continue;
|
|
126
|
+
const col: StatusColumn = { id: entry["id"], label: entry["label"] };
|
|
127
|
+
if (entry["isDone"] === true) col.isDone = true;
|
|
128
|
+
cleaned.push(col);
|
|
129
|
+
}
|
|
130
|
+
return ensureColumnsValid(cleaned);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Item helpers tied to columns ──────────────────────────────────
|
|
134
|
+
|
|
135
|
+
// id of the first column flagged isDone. Guaranteed to exist after
|
|
136
|
+
// normalizeColumns.
|
|
137
|
+
export function doneColumnId(columns: StatusColumn[]): string {
|
|
138
|
+
const done = columns.find((column) => column.isDone);
|
|
139
|
+
return done ? done.id : columns[columns.length - 1]!.id;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// id of the first non-done column, used as the default status when
|
|
143
|
+
// adding new items. Falls back to the done column if everything is
|
|
144
|
+
// somehow flagged done.
|
|
145
|
+
export function defaultStatusId(columns: StatusColumn[]): string {
|
|
146
|
+
const open = columns.find((column) => !column.isDone);
|
|
147
|
+
return open ? open.id : doneColumnId(columns);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Reconcile each item's `completed` boolean with the new done-column
|
|
151
|
+
// id. Items in the new done column are completed=true, items
|
|
152
|
+
// elsewhere are completed=false. Returns [updatedItems, changed]
|
|
153
|
+
// where `changed` says whether any item was actually rewritten —
|
|
154
|
+
// callers use that to decide whether to persist items along with
|
|
155
|
+
// the column change.
|
|
156
|
+
//
|
|
157
|
+
// This is the *only* place that mass-mutates `completed` based on
|
|
158
|
+
// status. The migration on read deliberately does NOT do this any
|
|
159
|
+
// more (so the legacy MCP `check` action's plain boolean flips keep
|
|
160
|
+
// working). Column operations are explicit user intent so it's safe
|
|
161
|
+
// to sync at that point.
|
|
162
|
+
export function resyncDoneMembership(items: TodoItem[], newDoneId: string): { items: TodoItem[]; changed: boolean } {
|
|
163
|
+
let changed = false;
|
|
164
|
+
const next = items.map((it): TodoItem => {
|
|
165
|
+
const shouldBeDone = it.status === newDoneId;
|
|
166
|
+
if (it.completed === shouldBeDone) return it;
|
|
167
|
+
changed = true;
|
|
168
|
+
return { ...it, completed: shouldBeDone };
|
|
169
|
+
});
|
|
170
|
+
return { items: next, changed };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Re-stripe order values for every item in `columnId`. Items already
|
|
174
|
+
// sorted by their existing `order` get reassigned to 1000, 2000, ...
|
|
175
|
+
// so two columns being merged together (handleDeleteColumn's refuge
|
|
176
|
+
// case) end up with unique, contiguous orders rather than colliding
|
|
177
|
+
// 1000s from each side.
|
|
178
|
+
function rebuildColumnOrder(items: TodoItem[], columnId: string): TodoItem[] {
|
|
179
|
+
const inColumn = items.filter((item) => item.status === columnId).sort((left, right) => (left.order ?? 0) - (right.order ?? 0));
|
|
180
|
+
const newOrders = new Map<string, number>();
|
|
181
|
+
inColumn.forEach((item, i) => newOrders.set(item.id, (i + 1) * ORDER_STEP));
|
|
182
|
+
return items.map((item): TodoItem => {
|
|
183
|
+
const newOrder = newOrders.get(item.id);
|
|
184
|
+
if (newOrder === undefined) return item;
|
|
185
|
+
return { ...item, order: newOrder };
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const ORDER_STEP = 1000;
|
|
190
|
+
|
|
191
|
+
// ── Action handlers ───────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
export interface AddColumnInput {
|
|
194
|
+
label?: string;
|
|
195
|
+
isDone?: boolean;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function handleAddColumn(columns: StatusColumn[], items: TodoItem[], input: AddColumnInput): ColumnsActionResult {
|
|
199
|
+
if (!input.label || input.label.trim().length === 0) {
|
|
200
|
+
return { kind: "error", status: 400, error: "label required" };
|
|
201
|
+
}
|
|
202
|
+
const baseId = slugify(input.label);
|
|
203
|
+
const id = uniqueId(baseId, new Set(columns.map((column) => column.id)));
|
|
204
|
+
const col: StatusColumn = { id, label: input.label.trim() };
|
|
205
|
+
if (input.isDone === true) col.isDone = true;
|
|
206
|
+
// If the new column is flagged done, demote any existing done
|
|
207
|
+
// columns (only one is allowed at a time) and resync items so the
|
|
208
|
+
// old done column's items are no longer marked completed. The new
|
|
209
|
+
// column itself is empty so there's nothing on its side to sync.
|
|
210
|
+
if (input.isDone === true) {
|
|
211
|
+
const nextColumns = [...columns.map((column) => ({ ...column, isDone: false })), col];
|
|
212
|
+
const { items: nextItems, changed } = resyncDoneMembership(items, id);
|
|
213
|
+
return {
|
|
214
|
+
kind: "success",
|
|
215
|
+
columns: nextColumns,
|
|
216
|
+
...(changed ? { items: nextItems } : {}),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return { kind: "success", columns: [...columns, col] };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface PatchColumnInput {
|
|
223
|
+
label?: string;
|
|
224
|
+
isDone?: boolean;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function handlePatchColumn(columns: StatusColumn[], id: string, input: PatchColumnInput, items: TodoItem[]): ColumnsActionResult {
|
|
228
|
+
const target = findColumn(columns, id);
|
|
229
|
+
if (!target) {
|
|
230
|
+
return { kind: "error", status: 404, error: `column not found: ${id}` };
|
|
231
|
+
}
|
|
232
|
+
const patched: StatusColumn = { id: target.id, label: target.label };
|
|
233
|
+
if (target.isDone) patched.isDone = true;
|
|
234
|
+
if (typeof input.label === "string" && input.label.trim().length > 0) {
|
|
235
|
+
patched.label = input.label.trim();
|
|
236
|
+
}
|
|
237
|
+
let nextColumns = columns.map((column) => (column.id === id ? patched : column));
|
|
238
|
+
// Toggling done flag is non-trivial: only one column may be done.
|
|
239
|
+
let itemsChanged = false;
|
|
240
|
+
let nextItems = items;
|
|
241
|
+
if (input.isDone === true && !target.isDone) {
|
|
242
|
+
// Promote this column to done; demote everyone else.
|
|
243
|
+
nextColumns = nextColumns.map((column) => (column.id === id ? { ...column, isDone: true } : { id: column.id, label: column.label }));
|
|
244
|
+
// Resync `completed` across all items: the new done column's
|
|
245
|
+
// items become true, the old done column's items become false.
|
|
246
|
+
// Doing this with the helper rather than a one-sided pass means
|
|
247
|
+
// both ends of the swap stay consistent.
|
|
248
|
+
const synced = resyncDoneMembership(items, id);
|
|
249
|
+
nextItems = synced.items;
|
|
250
|
+
itemsChanged = synced.changed;
|
|
251
|
+
} else if (input.isDone === false && target.isDone) {
|
|
252
|
+
// Refuse to demote the only done column — there must always be one.
|
|
253
|
+
return {
|
|
254
|
+
kind: "error",
|
|
255
|
+
status: 400,
|
|
256
|
+
error: "at least one column must be marked as done",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
kind: "success",
|
|
261
|
+
columns: nextColumns,
|
|
262
|
+
...(itemsChanged ? { items: nextItems } : {}),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function handleDeleteColumn(columns: StatusColumn[], id: string, items: TodoItem[]): ColumnsActionResult {
|
|
267
|
+
if (columns.length <= 1) {
|
|
268
|
+
return {
|
|
269
|
+
kind: "error",
|
|
270
|
+
status: 400,
|
|
271
|
+
error: "cannot delete the last remaining column",
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
const target = findColumn(columns, id);
|
|
275
|
+
if (!target) {
|
|
276
|
+
return { kind: "error", status: 404, error: `column not found: ${id}` };
|
|
277
|
+
}
|
|
278
|
+
const remaining = columns.filter((column) => column.id !== id);
|
|
279
|
+
// If we just removed the done column, promote the new last column.
|
|
280
|
+
let nextColumns = remaining;
|
|
281
|
+
if (target.isDone) {
|
|
282
|
+
nextColumns = remaining.map((column, i) => (i === remaining.length - 1 ? { ...column, isDone: true } : column));
|
|
283
|
+
}
|
|
284
|
+
const newDoneId = doneColumnId(nextColumns);
|
|
285
|
+
// Reassign orphaned items to the (possibly new) done column if the
|
|
286
|
+
// deleted column was done; otherwise to the new default open column.
|
|
287
|
+
const refugeId = target.isDone ? newDoneId : defaultStatusId(nextColumns);
|
|
288
|
+
let itemsChanged = false;
|
|
289
|
+
let nextItems = items.map((it): TodoItem => {
|
|
290
|
+
if (it.status !== id) return it;
|
|
291
|
+
itemsChanged = true;
|
|
292
|
+
return { ...it, status: refugeId };
|
|
293
|
+
});
|
|
294
|
+
if (itemsChanged) {
|
|
295
|
+
// The refuge column might have already had items in it; the ones
|
|
296
|
+
// we just merged in came with their original order values from
|
|
297
|
+
// the deleted column, which can collide with the refuge's
|
|
298
|
+
// existing orders. Re-stripe the whole refuge column to 1000,
|
|
299
|
+
// 2000, ... so the kanban sort stays unique and stable.
|
|
300
|
+
nextItems = rebuildColumnOrder(nextItems, refugeId);
|
|
301
|
+
}
|
|
302
|
+
// Resync the done flag across the result. Necessary in two
|
|
303
|
+
// scenarios: (a) we deleted the done column, so the new last column
|
|
304
|
+
// is now done and its existing items should flip to completed=true;
|
|
305
|
+
// (b) we deleted any other column whose items happened to be the
|
|
306
|
+
// refuge target — already handled by the migration above, but
|
|
307
|
+
// running the helper unconditionally keeps the rest of the items
|
|
308
|
+
// consistent too.
|
|
309
|
+
if (target.isDone) {
|
|
310
|
+
const synced = resyncDoneMembership(nextItems, newDoneId);
|
|
311
|
+
nextItems = synced.items;
|
|
312
|
+
itemsChanged = itemsChanged || synced.changed;
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
kind: "success",
|
|
316
|
+
columns: nextColumns,
|
|
317
|
+
...(itemsChanged ? { items: nextItems } : {}),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function handleReorderColumns(columns: StatusColumn[], ids: string[]): ColumnsActionResult {
|
|
322
|
+
if (!Array.isArray(ids)) {
|
|
323
|
+
return { kind: "error", status: 400, error: "ids array required" };
|
|
324
|
+
}
|
|
325
|
+
if (ids.length !== columns.length) {
|
|
326
|
+
return {
|
|
327
|
+
kind: "error",
|
|
328
|
+
status: 400,
|
|
329
|
+
error: "ids must contain every existing column id exactly once",
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
const known = new Set(columns.map((column) => column.id));
|
|
333
|
+
const seen = new Set<string>();
|
|
334
|
+
for (const id of ids) {
|
|
335
|
+
if (!known.has(id) || seen.has(id)) {
|
|
336
|
+
return {
|
|
337
|
+
kind: "error",
|
|
338
|
+
status: 400,
|
|
339
|
+
error: "ids must contain every existing column id exactly once",
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
seen.add(id);
|
|
343
|
+
}
|
|
344
|
+
const byId = new Map(columns.map((column) => [column.id, column]));
|
|
345
|
+
const next = ids.map((id) => byId.get(id)!);
|
|
346
|
+
return { kind: "success", columns: next };
|
|
347
|
+
}
|