mulmoclaude 0.1.2 → 0.4.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/bin/mulmoclaude.js +7 -24
- package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
- package/client/assets/index-CubzmCVK.css +2 -0
- package/client/assets/{index-D8rhwXLq.js → index-DtcyExH9.js} +80 -61
- package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
- package/client/index.html +2 -4
- package/package.json +13 -13
- package/server/agent/attachmentConverter.ts +2 -2
- package/server/agent/config.ts +12 -12
- package/server/agent/index.ts +9 -3
- package/server/agent/mcp-server.ts +19 -19
- package/server/agent/mcp-tools/index.ts +6 -6
- package/server/agent/mcp-tools/x.ts +7 -6
- package/server/agent/prompt.ts +195 -29
- package/server/agent/resumeFailover.ts +5 -5
- package/server/agent/sandboxMounts.ts +10 -10
- package/server/agent/stream.ts +4 -4
- package/server/api/auth/bearerAuth.ts +3 -3
- package/server/api/auth/token.ts +2 -2
- package/server/api/routes/agent.ts +21 -3
- package/server/api/routes/config.ts +1 -1
- package/server/api/routes/files.ts +22 -21
- package/server/api/routes/html.ts +2 -2
- package/server/api/routes/image.ts +7 -7
- package/server/api/routes/mulmo-script.ts +33 -31
- package/server/api/routes/pdf.ts +2 -2
- package/server/api/routes/plugins.ts +16 -6
- package/server/api/routes/roles.ts +2 -2
- package/server/api/routes/scheduler.ts +14 -12
- package/server/api/routes/schedulerHandlers.ts +12 -12
- package/server/api/routes/schedulerTasks.ts +19 -17
- package/server/api/routes/sessions.ts +26 -26
- package/server/api/routes/sessionsCursor.ts +4 -4
- package/server/api/routes/skills.ts +5 -5
- package/server/api/routes/sources.ts +3 -3
- package/server/api/routes/todosColumnsHandlers.ts +30 -30
- package/server/api/routes/todosHandlers.ts +1 -1
- package/server/api/routes/todosItemsHandlers.ts +14 -14
- package/server/api/routes/wiki.ts +36 -22
- package/server/api/sandboxStatus.ts +1 -1
- package/server/events/notifications.ts +6 -6
- package/server/events/pub-sub/index.ts +3 -3
- package/server/events/relay-client.ts +17 -16
- package/server/events/scheduler-adapter.ts +20 -20
- package/server/events/session-store/index.ts +10 -10
- package/server/events/task-manager/index.ts +7 -7
- package/server/index.ts +59 -65
- package/server/system/config.ts +5 -5
- package/server/system/credentials.ts +7 -5
- package/server/system/env.ts +5 -5
- package/server/utils/date.ts +18 -18
- package/server/utils/files/atomic.ts +16 -16
- package/server/utils/files/html-io.ts +5 -5
- package/server/utils/files/image-store.ts +19 -8
- package/server/utils/files/journal-io.ts +4 -4
- package/server/utils/files/json.ts +5 -5
- package/server/utils/files/markdown-store.ts +4 -4
- package/server/utils/files/naming.ts +2 -2
- package/server/utils/files/reference-dirs-io.ts +3 -3
- package/server/utils/files/roles-io.ts +12 -12
- package/server/utils/files/safe.ts +14 -14
- package/server/utils/files/scheduler-io.ts +5 -5
- package/server/utils/files/scheduler-overrides-io.ts +2 -2
- package/server/utils/files/session-io.ts +35 -35
- package/server/utils/files/spreadsheet-store.ts +7 -7
- package/server/utils/files/todos-io.ts +9 -9
- package/server/utils/files/user-tasks-io.ts +5 -5
- package/server/utils/files/workspace-io.ts +12 -12
- package/server/utils/gemini.ts +2 -2
- package/server/utils/gitignore.ts +9 -9
- package/server/utils/json.ts +5 -5
- package/server/utils/logBackgroundError.ts +12 -3
- package/server/utils/markdown.ts +5 -5
- package/server/utils/port.d.mts +6 -0
- package/server/utils/port.mjs +48 -0
- package/server/utils/request.ts +12 -6
- package/server/utils/spawn.ts +1 -1
- package/server/utils/types.ts +2 -2
- package/server/workspace/chat-index/indexer.ts +15 -15
- package/server/workspace/chat-index/summarizer.ts +4 -4
- package/server/workspace/custom-dirs.ts +16 -16
- package/server/workspace/journal/archivist.ts +35 -35
- package/server/workspace/journal/dailyPass.ts +31 -28
- package/server/workspace/journal/diff.ts +2 -2
- package/server/workspace/journal/index.ts +4 -4
- package/server/workspace/journal/indexFile.ts +29 -25
- package/server/workspace/journal/optimizationPass.ts +2 -2
- package/server/workspace/journal/state.ts +6 -6
- package/server/workspace/paths.ts +3 -3
- package/server/workspace/reference-dirs.ts +20 -20
- package/server/workspace/roles.ts +6 -6
- package/server/workspace/skills/discovery.ts +4 -4
- package/server/workspace/skills/parser.ts +6 -6
- package/server/workspace/skills/scheduler.ts +3 -3
- package/server/workspace/skills/user-tasks.ts +34 -34
- package/server/workspace/skills/writer.ts +3 -3
- package/server/workspace/sources/arxivDiscovery.ts +10 -10
- package/server/workspace/sources/classifier.ts +7 -7
- package/server/workspace/sources/fetchers/arxiv.ts +7 -7
- package/server/workspace/sources/fetchers/githubIssues.ts +7 -7
- package/server/workspace/sources/fetchers/githubReleases.ts +7 -7
- package/server/workspace/sources/fetchers/rss.ts +5 -5
- package/server/workspace/sources/fetchers/rssParser.ts +4 -4
- package/server/workspace/sources/interests.ts +12 -12
- package/server/workspace/sources/paths.ts +6 -6
- package/server/workspace/sources/pipeline/fetch.ts +36 -13
- package/server/workspace/sources/pipeline/index.ts +8 -13
- package/server/workspace/sources/pipeline/notify.ts +3 -3
- package/server/workspace/sources/pipeline/plan.ts +15 -13
- package/server/workspace/sources/pipeline/write.ts +5 -5
- package/server/workspace/sources/rateLimiter.ts +1 -1
- package/server/workspace/sources/registry.ts +16 -16
- package/server/workspace/sources/robots.ts +14 -14
- package/server/workspace/sources/sourceState.ts +17 -10
- package/server/workspace/sources/types.ts +9 -0
- package/server/workspace/sources/urls.ts +1 -1
- package/server/workspace/tool-trace/classify.ts +4 -4
- package/server/workspace/tool-trace/index.ts +1 -1
- package/server/workspace/tool-trace/writeSearch.ts +26 -16
- package/server/workspace/wiki-backlinks/index.ts +8 -8
- package/server/workspace/wiki-backlinks/sessionBacklinks.ts +15 -15
- package/server/workspace/workspace.ts +7 -7
- package/src/App.vue +315 -141
- package/src/components/CanvasViewToggle.vue +10 -7
- package/src/components/ChatInput.vue +67 -33
- package/src/components/FileContentHeader.vue +7 -4
- package/src/components/FileContentRenderer.vue +20 -6
- package/src/components/FileTree.vue +6 -3
- package/src/components/FileTreePane.vue +11 -8
- package/src/components/FilesView.vue +5 -3
- package/src/components/LockStatusPopup.vue +17 -14
- package/src/components/NotificationBell.vue +14 -5
- package/src/components/NotificationToast.vue +6 -3
- package/src/components/PluginLauncher.vue +19 -56
- package/src/components/RightSidebar.vue +13 -10
- package/src/components/RoleSelector.vue +2 -2
- package/src/components/SessionHistoryPanel.vue +38 -34
- package/src/components/SessionTabBar.vue +8 -10
- package/src/components/SettingsMcpTab.vue +49 -36
- package/src/components/SettingsModal.vue +24 -22
- package/src/components/SettingsReferenceDirsTab.vue +39 -34
- package/src/components/SettingsWorkspaceDirsTab.vue +37 -27
- package/src/components/SidebarHeader.vue +25 -4
- package/src/components/StackView.vue +4 -1
- package/src/components/SuggestionsPanel.vue +7 -4
- package/src/components/TodoExplorer.vue +26 -15
- package/src/components/ToolResultsPanel.vue +27 -13
- package/src/components/todo/TodoAddDialog.vue +19 -14
- package/src/components/todo/TodoEditDialog.vue +7 -2
- package/src/components/todo/TodoEditPanel.vue +17 -12
- package/src/components/todo/TodoKanbanView.vue +10 -5
- package/src/components/todo/TodoListView.vue +10 -7
- package/src/components/todo/TodoTableView.vue +5 -2
- package/src/composables/useAppApi.ts +9 -0
- package/src/composables/useClickOutside.ts +2 -2
- package/src/composables/useDynamicFavicon.ts +172 -37
- package/src/composables/useEventListeners.ts +7 -8
- package/src/composables/useFaviconState.ts +13 -2
- package/src/composables/useFileSelection.ts +24 -6
- package/src/composables/useFreshPluginData.ts +3 -3
- package/src/composables/useKeyNavigation.ts +11 -11
- package/src/composables/useLayoutMode.ts +32 -0
- package/src/composables/useMcpTools.ts +2 -2
- package/src/composables/useNotifications.ts +3 -3
- package/src/composables/usePdfDownload.ts +4 -4
- package/src/composables/usePendingCalls.ts +1 -1
- package/src/composables/usePubSub.ts +10 -10
- package/src/composables/useRoles.ts +1 -1
- package/src/composables/useSandboxStatus.ts +1 -1
- package/src/composables/useSessionDerived.ts +3 -3
- package/src/composables/useSessionHistory.ts +7 -17
- package/src/composables/useSessionSync.ts +8 -8
- package/src/composables/useViewLayout.ts +20 -34
- package/src/config/roles.ts +2 -2
- package/src/lang/de.ts +536 -0
- package/src/lang/en.ts +558 -0
- package/src/lang/es.ts +543 -0
- package/src/lang/fr.ts +536 -0
- package/src/lang/ja.ts +536 -0
- package/src/lang/ko.ts +540 -0
- package/src/lang/pt-BR.ts +534 -0
- package/src/lang/zh.ts +537 -0
- package/src/lib/vue-i18n.ts +97 -0
- package/src/main.ts +2 -0
- package/src/plugins/canvas/View.vue +102 -186
- package/src/plugins/canvas/definition.ts +0 -8
- package/src/plugins/chart/Preview.vue +5 -5
- package/src/plugins/chart/View.vue +9 -4
- package/src/plugins/manageRoles/Preview.vue +4 -1
- package/src/plugins/manageRoles/View.vue +59 -43
- package/src/plugins/manageSkills/Preview.vue +8 -3
- package/src/plugins/manageSkills/View.vue +29 -25
- package/src/plugins/manageSource/Preview.vue +2 -2
- package/src/plugins/manageSource/View.vue +73 -52
- package/src/plugins/markdown/Preview.vue +1 -1
- package/src/plugins/markdown/View.vue +26 -36
- package/src/plugins/presentHtml/Preview.vue +1 -1
- package/src/plugins/presentHtml/View.vue +7 -4
- package/src/plugins/presentHtml/helpers.ts +8 -8
- package/src/plugins/presentMulmoScript/Preview.vue +1 -1
- package/src/plugins/presentMulmoScript/View.vue +40 -30
- package/src/plugins/presentMulmoScript/helpers.ts +1 -1
- package/src/plugins/scheduler/Preview.vue +13 -10
- package/src/plugins/scheduler/TasksTab.vue +57 -28
- package/src/plugins/scheduler/View.vue +28 -19
- package/src/plugins/scheduler/formatSchedule.ts +93 -0
- package/src/plugins/spreadsheet/Preview.vue +8 -3
- package/src/plugins/spreadsheet/View.vue +21 -12
- package/src/plugins/textResponse/Preview.vue +15 -58
- package/src/plugins/textResponse/View.vue +29 -9
- package/src/plugins/todo/Preview.vue +13 -8
- package/src/plugins/todo/View.vue +38 -24
- package/src/plugins/todo/composables/useTodos.ts +5 -5
- package/src/plugins/ui-image/ImagePreview.vue +6 -3
- package/src/plugins/ui-image/ImageView.vue +7 -4
- package/src/plugins/wiki/Preview.vue +10 -7
- package/src/plugins/wiki/View.vue +202 -81
- package/src/plugins/wiki/helpers.ts +4 -4
- package/src/plugins/wiki/route.ts +112 -0
- package/src/router/guards.ts +46 -28
- package/src/router/index.ts +41 -26
- package/src/types/session.ts +4 -3
- package/src/types/vue-i18n.d.ts +20 -0
- package/src/utils/agent/request.ts +22 -3
- package/src/utils/canvas/layoutMode.ts +26 -0
- package/src/utils/dom/scrollable.ts +2 -2
- package/src/utils/files/expandedDirs.ts +1 -1
- package/src/utils/files/sortChildren.ts +6 -6
- package/src/utils/format/frontmatter.ts +6 -6
- package/src/utils/image/cacheBust.ts +16 -0
- package/src/utils/image/resolve.ts +16 -0
- package/src/utils/image/rewriteMarkdownImageRefs.ts +5 -5
- package/src/utils/markdown/extractFirstH1.ts +2 -2
- package/src/utils/path/relativeLink.ts +15 -15
- package/src/utils/path/workspaceLinkRouter.ts +81 -0
- package/src/utils/role/icon.ts +2 -2
- package/src/utils/role/merge.ts +2 -2
- package/src/utils/role/plugins.ts +1 -1
- package/src/utils/session/sessionFactory.ts +2 -2
- package/src/utils/session/sessionHelpers.ts +2 -2
- package/src/utils/tools/dedup.ts +4 -4
- package/src/utils/tools/result.ts +3 -3
- package/src/utils/types.ts +2 -2
- package/src/vite-env.d.ts +9 -0
- package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
- package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
- package/client/assets/index-KNLBjwuh.css +0 -1
- package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
- package/src/composables/useCanvasViewMode.ts +0 -121
- package/src/utils/canvas/viewMode.ts +0 -46
- /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Router, Request, Response } from "express";
|
|
2
|
-
import
|
|
2
|
+
import { realpathSync } from "fs";
|
|
3
3
|
import { readdir, stat } from "fs/promises";
|
|
4
4
|
import { readTextSafe } from "../../utils/files/safe.js";
|
|
5
5
|
import path from "path";
|
|
@@ -26,14 +26,14 @@ interface SessionMeta {
|
|
|
26
26
|
origin?: SessionOrigin;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
async function readSessionMeta(__chatDir: string,
|
|
29
|
+
async function readSessionMeta(__chatDir: string, sessionId: string): Promise<SessionMeta | null> {
|
|
30
30
|
// Try new-style .json meta first
|
|
31
|
-
const meta = await readSessionMetaIO(
|
|
31
|
+
const meta = await readSessionMetaIO(sessionId);
|
|
32
32
|
if (meta?.roleId && meta?.startedAt) {
|
|
33
33
|
return meta as SessionMeta;
|
|
34
34
|
}
|
|
35
35
|
// Legacy: read first line of .jsonl
|
|
36
|
-
const jsonl = await readSessionJsonl(
|
|
36
|
+
const jsonl = await readSessionJsonl(sessionId);
|
|
37
37
|
if (jsonl) {
|
|
38
38
|
const first = jsonl.split("\n").find(Boolean);
|
|
39
39
|
if (first) {
|
|
@@ -102,19 +102,19 @@ const WINDOW_MS = env.sessionsListWindowDays * ONE_DAY_MS;
|
|
|
102
102
|
export async function loadAllSessions(): Promise<{ summary: SessionSummary; changeMs: number }[]> {
|
|
103
103
|
const chatDir = WORKSPACE_PATHS.chat;
|
|
104
104
|
const manifest = await readManifest(workspacePath);
|
|
105
|
-
const indexById = new Map<string, ChatIndexEntry>(manifest.entries.map((
|
|
105
|
+
const indexById = new Map<string, ChatIndexEntry>(manifest.entries.map((entry) => [entry.id, entry]));
|
|
106
106
|
const cutoff = WINDOW_MS > 0 ? Date.now() - WINDOW_MS : 0;
|
|
107
107
|
|
|
108
|
-
const files = (await readdir(chatDir)).filter((
|
|
108
|
+
const files = (await readdir(chatDir)).filter((fileName) => fileName.endsWith(".jsonl"));
|
|
109
109
|
const rows = await Promise.all(
|
|
110
110
|
files.map(async (file) => {
|
|
111
|
-
const
|
|
111
|
+
const sessionId = file.replace(".jsonl", "");
|
|
112
112
|
try {
|
|
113
113
|
// stat only — no readFile on .jsonl content
|
|
114
|
-
const fileStat = await stat(sessionJsonlAbsPath(
|
|
114
|
+
const fileStat = await stat(sessionJsonlAbsPath(sessionId));
|
|
115
115
|
if (cutoff > 0 && fileStat.mtimeMs < cutoff) return null;
|
|
116
116
|
|
|
117
|
-
const meta = await readSessionMeta(chatDir,
|
|
117
|
+
const meta = await readSessionMeta(chatDir, sessionId);
|
|
118
118
|
if (!meta) return null;
|
|
119
119
|
|
|
120
120
|
// The meta sidecar bumps its mtime on hasUnread / origin
|
|
@@ -122,20 +122,20 @@ export async function loadAllSessions(): Promise<{ summary: SessionSummary; chan
|
|
|
122
122
|
// pick up drains of background generations (which only touch
|
|
123
123
|
// meta, not the jsonl). Missing stat (brand-new session
|
|
124
124
|
// before its first meta write) contributes 0.
|
|
125
|
-
const metaMtimeMs = await stat(sessionMetaAbsPath(
|
|
126
|
-
.then((
|
|
125
|
+
const metaMtimeMs = await stat(sessionMetaAbsPath(sessionId))
|
|
126
|
+
.then((stats) => stats.mtimeMs)
|
|
127
127
|
.catch(() => 0);
|
|
128
128
|
|
|
129
|
-
const indexEntry = indexById.get(
|
|
129
|
+
const indexEntry = indexById.get(sessionId);
|
|
130
130
|
// Prefer AI title → meta.firstUserMessage → empty.
|
|
131
131
|
// `summary` and `keywords` are spread conditionally
|
|
132
132
|
// to respect the server tsconfig's
|
|
133
133
|
// exactOptionalPropertyTypes.
|
|
134
134
|
const preview = indexEntry?.title ?? meta.firstUserMessage ?? "";
|
|
135
135
|
|
|
136
|
-
const live = getSession(
|
|
136
|
+
const live = getSession(sessionId);
|
|
137
137
|
const summary: SessionSummary = {
|
|
138
|
-
id,
|
|
138
|
+
id: sessionId,
|
|
139
139
|
roleId: meta.roleId,
|
|
140
140
|
startedAt: meta.startedAt,
|
|
141
141
|
updatedAt: new Date(fileStat.mtimeMs).toISOString(),
|
|
@@ -161,7 +161,7 @@ export async function loadAllSessions(): Promise<{ summary: SessionSummary; chan
|
|
|
161
161
|
}
|
|
162
162
|
}),
|
|
163
163
|
);
|
|
164
|
-
return rows.filter((
|
|
164
|
+
return rows.filter((row): row is { summary: SessionSummary; changeMs: number } => row !== null);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
router.get(API_ROUTES.sessions.list, async (req: Request<object, SessionsResponse, object, SessionsQuery>, res: Response<SessionsResponse>) => {
|
|
@@ -173,15 +173,15 @@ router.get(API_ROUTES.sessions.list, async (req: Request<object, SessionsRespons
|
|
|
173
173
|
// of whether it's in the diff. Echoing the same cursor back on an
|
|
174
174
|
// empty diff (nothing changed since `?since=`) is fine; the
|
|
175
175
|
// client no-ops.
|
|
176
|
-
const maxChangeMs = rows.reduce((acc,
|
|
176
|
+
const maxChangeMs = rows.reduce((acc, row) => Math.max(acc, row.changeMs), 0);
|
|
177
177
|
|
|
178
|
-
const filtered = sinceMs > 0 ? rows.filter((
|
|
178
|
+
const filtered = sinceMs > 0 ? rows.filter((row) => row.changeMs > sinceMs) : rows;
|
|
179
179
|
|
|
180
|
-
const sessions = filtered.map((
|
|
181
|
-
sessions.sort((
|
|
182
|
-
const byUpdated = new Date(
|
|
180
|
+
const sessions = filtered.map((row) => row.summary);
|
|
181
|
+
sessions.sort((leftSession, rightSession) => {
|
|
182
|
+
const byUpdated = new Date(rightSession.updatedAt).getTime() - new Date(leftSession.updatedAt).getTime();
|
|
183
183
|
if (byUpdated !== 0) return byUpdated;
|
|
184
|
-
return new Date(
|
|
184
|
+
return new Date(rightSession.startedAt).getTime() - new Date(leftSession.startedAt).getTime();
|
|
185
185
|
});
|
|
186
186
|
|
|
187
187
|
res.json({
|
|
@@ -207,13 +207,13 @@ interface SessionErrorResponse {
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
router.get(API_ROUTES.sessions.detail, async (req: Request<SessionIdParams>, res: Response<unknown[] | SessionErrorResponse>) => {
|
|
210
|
-
const { id } = req.params;
|
|
210
|
+
const { id: sessionId } = req.params;
|
|
211
211
|
const chatDir = WORKSPACE_PATHS.chat;
|
|
212
212
|
try {
|
|
213
|
-
const meta = await readSessionMeta(chatDir,
|
|
214
|
-
const content = await readSessionJsonl(
|
|
213
|
+
const meta = await readSessionMeta(chatDir, sessionId);
|
|
214
|
+
const content = await readSessionJsonl(sessionId);
|
|
215
215
|
if (!content) {
|
|
216
|
-
notFound(res, `Session ${
|
|
216
|
+
notFound(res, `Session ${sessionId} not found`);
|
|
217
217
|
return;
|
|
218
218
|
}
|
|
219
219
|
const entries = (
|
|
@@ -242,7 +242,7 @@ router.get(API_ROUTES.sessions.detail, async (req: Request<SessionIdParams>, res
|
|
|
242
242
|
const storiesDir = path.resolve(WORKSPACE_PATHS.stories);
|
|
243
243
|
let storiesReal: string;
|
|
244
244
|
try {
|
|
245
|
-
storiesReal =
|
|
245
|
+
storiesReal = realpathSync(storiesDir);
|
|
246
246
|
} catch {
|
|
247
247
|
return entry;
|
|
248
248
|
}
|
|
@@ -21,8 +21,8 @@ const CURSOR_PREFIX = "v1:";
|
|
|
21
21
|
* fall back to when an incoming cursor is malformed.
|
|
22
22
|
*/
|
|
23
23
|
export function encodeCursor(changeMs: number): string {
|
|
24
|
-
const
|
|
25
|
-
return `${CURSOR_PREFIX}${
|
|
24
|
+
const floored = Number.isFinite(changeMs) && changeMs > 0 ? Math.floor(changeMs) : 0;
|
|
25
|
+
return `${CURSOR_PREFIX}${floored}`;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -36,8 +36,8 @@ export function encodeCursor(changeMs: number): string {
|
|
|
36
36
|
export function parseCursor(raw: unknown): number {
|
|
37
37
|
if (typeof raw !== "string") return 0;
|
|
38
38
|
if (!raw.startsWith(CURSOR_PREFIX)) return 0;
|
|
39
|
-
const
|
|
40
|
-
return Number.isFinite(
|
|
39
|
+
const parsed = Number(raw.slice(CURSOR_PREFIX.length));
|
|
40
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
@@ -55,17 +55,17 @@ interface DeleteSkillResponse {
|
|
|
55
55
|
router.get(API_ROUTES.skills.list, async (_req: Request, res: Response<SkillsListResponse>) => {
|
|
56
56
|
const skills = await discoverSkills({ workspaceRoot: workspacePath });
|
|
57
57
|
res.json({
|
|
58
|
-
skills: skills.map((
|
|
59
|
-
name:
|
|
60
|
-
description:
|
|
61
|
-
source:
|
|
58
|
+
skills: skills.map((skill) => ({
|
|
59
|
+
name: skill.name,
|
|
60
|
+
description: skill.description,
|
|
61
|
+
source: skill.source,
|
|
62
62
|
})),
|
|
63
63
|
});
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
router.get(API_ROUTES.skills.detail, async (req: Request<{ name: string }>, res: Response<SkillDetailResponse | ErrorResponse>) => {
|
|
67
67
|
const skills = await discoverSkills({ workspaceRoot: workspacePath });
|
|
68
|
-
const skill = skills.find((
|
|
68
|
+
const skill = skills.find((candidate) => candidate.name === req.params.name);
|
|
69
69
|
if (!skill) {
|
|
70
70
|
notFound(res, `skill not found: ${req.params.name}`);
|
|
71
71
|
return;
|
|
@@ -312,7 +312,7 @@ async function handleRegister(body: ManageSourceBody, res: Response<ManageSource
|
|
|
312
312
|
async function handleRemove(body: ManageSourceBody, res: Response<ManageSourceSuccess | ErrorResponse>): Promise<void> {
|
|
313
313
|
const slug = typeof body.slug === "string" ? body.slug.trim() : "";
|
|
314
314
|
if (!isValidSlug(slug)) {
|
|
315
|
-
res
|
|
315
|
+
badRequest(res, "slug is required and must be a valid slug");
|
|
316
316
|
return;
|
|
317
317
|
}
|
|
318
318
|
const removed = await deleteSource(workspacePath, slug);
|
|
@@ -525,8 +525,8 @@ async function resolveCategories(parsed: ParsedRegisterBody): Promise<{ categori
|
|
|
525
525
|
|
|
526
526
|
function isHttpUrl(raw: string): boolean {
|
|
527
527
|
try {
|
|
528
|
-
const
|
|
529
|
-
return
|
|
528
|
+
const url = new URL(raw);
|
|
529
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
530
530
|
} catch {
|
|
531
531
|
return false;
|
|
532
532
|
}
|
|
@@ -79,8 +79,8 @@ function uniqueId(base: string, existingIds: ReadonlySet<string>): string {
|
|
|
79
79
|
|
|
80
80
|
// ── Validation helpers ────────────────────────────────────────────
|
|
81
81
|
|
|
82
|
-
function findColumn(columns: StatusColumn[],
|
|
83
|
-
return columns.find((column) => column.id ===
|
|
82
|
+
function findColumn(columns: StatusColumn[], columnId: string): StatusColumn | undefined {
|
|
83
|
+
return columns.find((column) => column.id === columnId);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
function ensureColumnsValid(columns: StatusColumn[]): StatusColumn[] {
|
|
@@ -161,11 +161,11 @@ export function defaultStatusId(columns: StatusColumn[]): string {
|
|
|
161
161
|
// to sync at that point.
|
|
162
162
|
export function resyncDoneMembership(items: TodoItem[], newDoneId: string): { items: TodoItem[]; changed: boolean } {
|
|
163
163
|
let changed = false;
|
|
164
|
-
const next = items.map((
|
|
165
|
-
const shouldBeDone =
|
|
166
|
-
if (
|
|
164
|
+
const next = items.map((item): TodoItem => {
|
|
165
|
+
const shouldBeDone = item.status === newDoneId;
|
|
166
|
+
if (item.completed === shouldBeDone) return item;
|
|
167
167
|
changed = true;
|
|
168
|
-
return { ...
|
|
168
|
+
return { ...item, completed: shouldBeDone };
|
|
169
169
|
});
|
|
170
170
|
return { items: next, changed };
|
|
171
171
|
}
|
|
@@ -178,7 +178,7 @@ export function resyncDoneMembership(items: TodoItem[], newDoneId: string): { it
|
|
|
178
178
|
function rebuildColumnOrder(items: TodoItem[], columnId: string): TodoItem[] {
|
|
179
179
|
const inColumn = items.filter((item) => item.status === columnId).sort((left, right) => (left.order ?? 0) - (right.order ?? 0));
|
|
180
180
|
const newOrders = new Map<string, number>();
|
|
181
|
-
inColumn.forEach((item,
|
|
181
|
+
inColumn.forEach((item, index) => newOrders.set(item.id, (index + 1) * ORDER_STEP));
|
|
182
182
|
return items.map((item): TodoItem => {
|
|
183
183
|
const newOrder = newOrders.get(item.id);
|
|
184
184
|
if (newOrder === undefined) return item;
|
|
@@ -200,23 +200,23 @@ export function handleAddColumn(columns: StatusColumn[], items: TodoItem[], inpu
|
|
|
200
200
|
return { kind: "error", status: 400, error: "label required" };
|
|
201
201
|
}
|
|
202
202
|
const baseId = slugify(input.label);
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
if (input.isDone === true)
|
|
203
|
+
const columnId = uniqueId(baseId, new Set(columns.map((column) => column.id)));
|
|
204
|
+
const columnToAdd: StatusColumn = { id: columnId, label: input.label.trim() };
|
|
205
|
+
if (input.isDone === true) columnToAdd.isDone = true;
|
|
206
206
|
// If the new column is flagged done, demote any existing done
|
|
207
207
|
// columns (only one is allowed at a time) and resync items so the
|
|
208
208
|
// old done column's items are no longer marked completed. The new
|
|
209
209
|
// column itself is empty so there's nothing on its side to sync.
|
|
210
210
|
if (input.isDone === true) {
|
|
211
|
-
const nextColumns = [...columns.map((column) => ({ ...column, isDone: false })),
|
|
212
|
-
const { items: nextItems, changed } = resyncDoneMembership(items,
|
|
211
|
+
const nextColumns = [...columns.map((column) => ({ ...column, isDone: false })), columnToAdd];
|
|
212
|
+
const { items: nextItems, changed } = resyncDoneMembership(items, columnId);
|
|
213
213
|
return {
|
|
214
214
|
kind: "success",
|
|
215
215
|
columns: nextColumns,
|
|
216
216
|
...(changed ? { items: nextItems } : {}),
|
|
217
217
|
};
|
|
218
218
|
}
|
|
219
|
-
return { kind: "success", columns: [...columns,
|
|
219
|
+
return { kind: "success", columns: [...columns, columnToAdd] };
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
export interface PatchColumnInput {
|
|
@@ -224,28 +224,28 @@ export interface PatchColumnInput {
|
|
|
224
224
|
isDone?: boolean;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
export function handlePatchColumn(columns: StatusColumn[],
|
|
228
|
-
const target = findColumn(columns,
|
|
227
|
+
export function handlePatchColumn(columns: StatusColumn[], columnId: string, input: PatchColumnInput, items: TodoItem[]): ColumnsActionResult {
|
|
228
|
+
const target = findColumn(columns, columnId);
|
|
229
229
|
if (!target) {
|
|
230
|
-
return { kind: "error", status: 404, error: `column not found: ${
|
|
230
|
+
return { kind: "error", status: 404, error: `column not found: ${columnId}` };
|
|
231
231
|
}
|
|
232
232
|
const patched: StatusColumn = { id: target.id, label: target.label };
|
|
233
233
|
if (target.isDone) patched.isDone = true;
|
|
234
234
|
if (typeof input.label === "string" && input.label.trim().length > 0) {
|
|
235
235
|
patched.label = input.label.trim();
|
|
236
236
|
}
|
|
237
|
-
let nextColumns = columns.map((column) => (column.id ===
|
|
237
|
+
let nextColumns = columns.map((column) => (column.id === columnId ? patched : column));
|
|
238
238
|
// Toggling done flag is non-trivial: only one column may be done.
|
|
239
239
|
let itemsChanged = false;
|
|
240
240
|
let nextItems = items;
|
|
241
241
|
if (input.isDone === true && !target.isDone) {
|
|
242
242
|
// Promote this column to done; demote everyone else.
|
|
243
|
-
nextColumns = nextColumns.map((column) => (column.id ===
|
|
243
|
+
nextColumns = nextColumns.map((column) => (column.id === columnId ? { ...column, isDone: true } : { id: column.id, label: column.label }));
|
|
244
244
|
// Resync `completed` across all items: the new done column's
|
|
245
245
|
// items become true, the old done column's items become false.
|
|
246
246
|
// Doing this with the helper rather than a one-sided pass means
|
|
247
247
|
// both ends of the swap stay consistent.
|
|
248
|
-
const synced = resyncDoneMembership(items,
|
|
248
|
+
const synced = resyncDoneMembership(items, columnId);
|
|
249
249
|
nextItems = synced.items;
|
|
250
250
|
itemsChanged = synced.changed;
|
|
251
251
|
} else if (input.isDone === false && target.isDone) {
|
|
@@ -263,7 +263,7 @@ export function handlePatchColumn(columns: StatusColumn[], id: string, input: Pa
|
|
|
263
263
|
};
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
export function handleDeleteColumn(columns: StatusColumn[],
|
|
266
|
+
export function handleDeleteColumn(columns: StatusColumn[], columnId: string, items: TodoItem[]): ColumnsActionResult {
|
|
267
267
|
if (columns.length <= 1) {
|
|
268
268
|
return {
|
|
269
269
|
kind: "error",
|
|
@@ -271,11 +271,11 @@ export function handleDeleteColumn(columns: StatusColumn[], id: string, items: T
|
|
|
271
271
|
error: "cannot delete the last remaining column",
|
|
272
272
|
};
|
|
273
273
|
}
|
|
274
|
-
const target = findColumn(columns,
|
|
274
|
+
const target = findColumn(columns, columnId);
|
|
275
275
|
if (!target) {
|
|
276
|
-
return { kind: "error", status: 404, error: `column not found: ${
|
|
276
|
+
return { kind: "error", status: 404, error: `column not found: ${columnId}` };
|
|
277
277
|
}
|
|
278
|
-
const remaining = columns.filter((column) => column.id !==
|
|
278
|
+
const remaining = columns.filter((column) => column.id !== columnId);
|
|
279
279
|
// If we just removed the done column, promote the new last column.
|
|
280
280
|
let nextColumns = remaining;
|
|
281
281
|
if (target.isDone) {
|
|
@@ -286,10 +286,10 @@ export function handleDeleteColumn(columns: StatusColumn[], id: string, items: T
|
|
|
286
286
|
// deleted column was done; otherwise to the new default open column.
|
|
287
287
|
const refugeId = target.isDone ? newDoneId : defaultStatusId(nextColumns);
|
|
288
288
|
let itemsChanged = false;
|
|
289
|
-
let nextItems = items.map((
|
|
290
|
-
if (
|
|
289
|
+
let nextItems = items.map((item): TodoItem => {
|
|
290
|
+
if (item.status !== columnId) return item;
|
|
291
291
|
itemsChanged = true;
|
|
292
|
-
return { ...
|
|
292
|
+
return { ...item, status: refugeId };
|
|
293
293
|
});
|
|
294
294
|
if (itemsChanged) {
|
|
295
295
|
// The refuge column might have already had items in it; the ones
|
|
@@ -331,17 +331,17 @@ export function handleReorderColumns(columns: StatusColumn[], ids: string[]): Co
|
|
|
331
331
|
}
|
|
332
332
|
const known = new Set(columns.map((column) => column.id));
|
|
333
333
|
const seen = new Set<string>();
|
|
334
|
-
for (const
|
|
335
|
-
if (!known.has(
|
|
334
|
+
for (const columnId of ids) {
|
|
335
|
+
if (!known.has(columnId) || seen.has(columnId)) {
|
|
336
336
|
return {
|
|
337
337
|
kind: "error",
|
|
338
338
|
status: 400,
|
|
339
339
|
error: "ids must contain every existing column id exactly once",
|
|
340
340
|
};
|
|
341
341
|
}
|
|
342
|
-
seen.add(
|
|
342
|
+
seen.add(columnId);
|
|
343
343
|
}
|
|
344
344
|
const byId = new Map(columns.map((column) => [column.id, column]));
|
|
345
|
-
const next = ids.map((
|
|
345
|
+
const next = ids.map((columnId) => byId.get(columnId)!);
|
|
346
346
|
return { kind: "success", columns: next };
|
|
347
347
|
}
|
|
@@ -242,7 +242,7 @@ export function handleRemoveLabel(items: TodoItem[], input: TodosActionInput): T
|
|
|
242
242
|
|
|
243
243
|
export function handleListLabels(items: TodoItem[]): TodosActionResult {
|
|
244
244
|
const inventory = listLabelsWithCount(items);
|
|
245
|
-
const summary = inventory.map((
|
|
245
|
+
const summary = inventory.map((entry) => `${entry.label} (${entry.count})`).join(", ");
|
|
246
246
|
const message = inventory.length === 0 ? "No labels in use" : `${inventory.length} label(s) in use: ${summary}`;
|
|
247
247
|
return {
|
|
248
248
|
kind: "success",
|
|
@@ -281,10 +281,10 @@ function applyCompletedPatch(updated: TodoItem, items: TodoItem[], columns: Stat
|
|
|
281
281
|
}
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
-
export function handlePatch(items: TodoItem[], columns: StatusColumn[],
|
|
285
|
-
const target = items.find((
|
|
284
|
+
export function handlePatch(items: TodoItem[], columns: StatusColumn[], itemId: string, input: PatchInput): ItemsActionResult {
|
|
285
|
+
const target = items.find((item) => item.id === itemId);
|
|
286
286
|
if (!target) {
|
|
287
|
-
return { kind: "error", status: 404, error: `item not found: ${
|
|
287
|
+
return { kind: "error", status: 404, error: `item not found: ${itemId}` };
|
|
288
288
|
}
|
|
289
289
|
const updated: TodoItem = { ...target };
|
|
290
290
|
|
|
@@ -305,7 +305,7 @@ export function handlePatch(items: TodoItem[], columns: StatusColumn[], id: stri
|
|
|
305
305
|
if (err) return err;
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
-
const next = items.map((item) => (item.id ===
|
|
308
|
+
const next = items.map((item) => (item.id === itemId ? updated : item));
|
|
309
309
|
return { kind: "success", items: next, item: updated };
|
|
310
310
|
}
|
|
311
311
|
|
|
@@ -323,10 +323,10 @@ export interface MoveInput {
|
|
|
323
323
|
position?: number;
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
-
export function handleMove(items: TodoItem[], columns: StatusColumn[],
|
|
327
|
-
const target = items.find((
|
|
326
|
+
export function handleMove(items: TodoItem[], columns: StatusColumn[], itemId: string, input: MoveInput): ItemsActionResult {
|
|
327
|
+
const target = items.find((item) => item.id === itemId);
|
|
328
328
|
if (!target) {
|
|
329
|
-
return { kind: "error", status: 404, error: `item not found: ${
|
|
329
|
+
return { kind: "error", status: 404, error: `item not found: ${itemId}` };
|
|
330
330
|
}
|
|
331
331
|
const validStatusIds = new Set(columns.map((column) => column.id));
|
|
332
332
|
const newStatus = input.status ?? target.status ?? defaultStatusId(columns);
|
|
@@ -345,7 +345,7 @@ export function handleMove(items: TodoItem[], columns: StatusColumn[], id: strin
|
|
|
345
345
|
};
|
|
346
346
|
// Re-collect the items in the target column with the moving item
|
|
347
347
|
// pulled out, then splice item back in at `position`.
|
|
348
|
-
const others = items.filter((item) => item.id !==
|
|
348
|
+
const others = items.filter((item) => item.id !== itemId && item.status === newStatus).sort((left, right) => (left.order ?? 0) - (right.order ?? 0));
|
|
349
349
|
const insertAt = clampPosition(input.position, others.length);
|
|
350
350
|
const reordered = [...others];
|
|
351
351
|
reordered.splice(insertAt, 0, updatedSelf);
|
|
@@ -354,7 +354,7 @@ export function handleMove(items: TodoItem[], columns: StatusColumn[], id: strin
|
|
|
354
354
|
reordered.forEach((item, i) => reorderedById.set(item.id, (i + 1) * ORDER_STEP));
|
|
355
355
|
const nextItems = items.map((item): TodoItem => {
|
|
356
356
|
const newOrder = reorderedById.get(item.id);
|
|
357
|
-
if (item.id ===
|
|
357
|
+
if (item.id === itemId) {
|
|
358
358
|
const out: TodoItem = {
|
|
359
359
|
...updatedSelf,
|
|
360
360
|
order: newOrder ?? updatedSelf.order ?? ORDER_STEP,
|
|
@@ -364,7 +364,7 @@ export function handleMove(items: TodoItem[], columns: StatusColumn[], id: strin
|
|
|
364
364
|
if (newOrder !== undefined) return { ...item, order: newOrder };
|
|
365
365
|
return item;
|
|
366
366
|
});
|
|
367
|
-
const finalSelf = nextItems.find((item) => item.id ===
|
|
367
|
+
const finalSelf = nextItems.find((item) => item.id === itemId)!;
|
|
368
368
|
return { kind: "success", items: nextItems, item: finalSelf };
|
|
369
369
|
}
|
|
370
370
|
|
|
@@ -377,10 +377,10 @@ function clampPosition(raw: number | undefined, max: number): number {
|
|
|
377
377
|
|
|
378
378
|
// ── Delete ────────────────────────────────────────────────────────
|
|
379
379
|
|
|
380
|
-
export function handleDeleteItem(items: TodoItem[],
|
|
381
|
-
const target = items.find((
|
|
380
|
+
export function handleDeleteItem(items: TodoItem[], itemId: string): ItemsActionResult {
|
|
381
|
+
const target = items.find((item) => item.id === itemId);
|
|
382
382
|
if (!target) {
|
|
383
|
-
return { kind: "error", status: 404, error: `item not found: ${
|
|
383
|
+
return { kind: "error", status: 404, error: `item not found: ${itemId}` };
|
|
384
384
|
}
|
|
385
|
-
return { kind: "success", items: items.filter((item) => item.id !==
|
|
385
|
+
return { kind: "success", items: items.filter((item) => item.id !== itemId) };
|
|
386
386
|
}
|
|
@@ -4,6 +4,7 @@ import { WORKSPACE_PATHS } from "../../workspace/paths.js";
|
|
|
4
4
|
import { readTextSafeSync, readTextSafe } from "../../utils/files/safe.js";
|
|
5
5
|
import { getPageIndex } from "./wiki/pageIndex.js";
|
|
6
6
|
import { badRequest } from "../../utils/httpError.js";
|
|
7
|
+
import { getOptionalStringQuery } from "../../utils/request.js";
|
|
7
8
|
import { API_ROUTES } from "../../../src/config/apiRoutes.js";
|
|
8
9
|
|
|
9
10
|
const router = Router();
|
|
@@ -46,7 +47,7 @@ function parseTableRow(trimmed: string): WikiPageEntry | null {
|
|
|
46
47
|
const cols = trimmed
|
|
47
48
|
.split("|")
|
|
48
49
|
.slice(1, -1)
|
|
49
|
-
.map((
|
|
50
|
+
.map((column) => column.trim().replace(/^`|`$/g, ""));
|
|
50
51
|
if (cols.length < 2) return null;
|
|
51
52
|
const slug = cols[0];
|
|
52
53
|
const title = cols[1] || slug;
|
|
@@ -70,11 +71,11 @@ export function extractSlugFromBulletHref(rawHref: string): string {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
function parseBulletLinkRow(trimmed: string): WikiPageEntry | null {
|
|
73
|
-
const
|
|
74
|
-
if (!
|
|
75
|
-
const title =
|
|
76
|
-
const href =
|
|
77
|
-
const desc =
|
|
74
|
+
const match = BULLET_LINK_PATTERN.exec(trimmed);
|
|
75
|
+
if (!match) return null;
|
|
76
|
+
const title = match[1].trim();
|
|
77
|
+
const href = match[2] ?? "";
|
|
78
|
+
const desc = match[3]?.trim() ?? "";
|
|
78
79
|
// Prefer the slug embedded in the href so non-ASCII titles keep
|
|
79
80
|
// a navigable slug. Fall back to slugifying the title only when
|
|
80
81
|
// the href has no recognisable slug (rare — usually means the
|
|
@@ -84,10 +85,10 @@ function parseBulletLinkRow(trimmed: string): WikiPageEntry | null {
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
function parseBulletWikiLinkRow(trimmed: string): WikiPageEntry | null {
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
89
|
-
const title =
|
|
90
|
-
const desc =
|
|
88
|
+
const match = BULLET_WIKI_LINK_PATTERN.exec(trimmed);
|
|
89
|
+
if (!match) return null;
|
|
90
|
+
const title = match[1].trim();
|
|
91
|
+
const desc = match[2]?.trim() ?? "";
|
|
91
92
|
return { title, slug: wikiSlugify(title), description: desc };
|
|
92
93
|
}
|
|
93
94
|
|
|
@@ -132,21 +133,34 @@ async function resolvePagePath(pageName: string): Promise<string | null> {
|
|
|
132
133
|
|
|
133
134
|
const slug = wikiSlugify(pageName);
|
|
134
135
|
|
|
135
|
-
|
|
136
|
-
|
|
136
|
+
if (slug.length > 0) {
|
|
137
|
+
const exact = slugs.get(slug);
|
|
138
|
+
if (exact) return path.join(dir, exact);
|
|
137
139
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
// Fuzzy: same `includes` semantics as the old sync path — iterate
|
|
141
|
+
// the index's keys, no filesystem access.
|
|
142
|
+
for (const [key, file] of slugs) {
|
|
143
|
+
if (slug.includes(key) || key.includes(slug)) {
|
|
144
|
+
return path.join(dir, file);
|
|
145
|
+
}
|
|
143
146
|
}
|
|
144
147
|
}
|
|
148
|
+
|
|
149
|
+
// Non-ASCII page names (e.g. Japanese [[wiki links]]) produce empty
|
|
150
|
+
// slugs after slugification. Fall back to matching by title in the
|
|
151
|
+
// wiki index so the link resolves to its page file.
|
|
152
|
+
const indexContent = readFileOrEmpty(indexFile());
|
|
153
|
+
const entries = parseIndexEntries(indexContent);
|
|
154
|
+
const titleMatch = entries.find((entry) => entry.title === pageName);
|
|
155
|
+
if (titleMatch && slugs.has(titleMatch.slug)) {
|
|
156
|
+
return path.join(dir, slugs.get(titleMatch.slug)!);
|
|
157
|
+
}
|
|
158
|
+
|
|
145
159
|
return null;
|
|
146
160
|
}
|
|
147
161
|
|
|
148
162
|
router.get(API_ROUTES.wiki.base, async (req: Request, res: Response<WikiResponse | ErrorResponse>) => {
|
|
149
|
-
const slug =
|
|
163
|
+
const slug = getOptionalStringQuery(req, "slug");
|
|
150
164
|
if (slug) {
|
|
151
165
|
const filePath = await resolvePagePath(slug);
|
|
152
166
|
const content = filePath ? readFileOrEmpty(filePath) : "";
|
|
@@ -276,7 +290,7 @@ export function findMissingFiles(pageEntries: readonly WikiPageEntry[], fileSlug
|
|
|
276
290
|
|
|
277
291
|
export function findBrokenLinksInPage(fileName: string, content: string, fileSlugs: ReadonlySet<string>): string[] {
|
|
278
292
|
const issues: string[] = [];
|
|
279
|
-
const wikiLinks = [...content.matchAll(WIKI_LINK_PATTERN)].map((
|
|
293
|
+
const wikiLinks = [...content.matchAll(WIKI_LINK_PATTERN)].map((match) => match[1]);
|
|
280
294
|
for (const link of wikiLinks) {
|
|
281
295
|
const linkSlug = wikiSlugify(link);
|
|
282
296
|
if (!fileSlugs.has(linkSlug)) {
|
|
@@ -302,7 +316,7 @@ async function collectLintIssues(): Promise<string[]> {
|
|
|
302
316
|
}
|
|
303
317
|
const indexContent = readFileOrEmpty(indexFile());
|
|
304
318
|
const pageEntries = parseIndexEntries(indexContent);
|
|
305
|
-
const indexedSlugs = new Set(pageEntries.map((
|
|
319
|
+
const indexedSlugs = new Set(pageEntries.map((entry) => entry.slug));
|
|
306
320
|
const pageFiles = [...slugs.values()];
|
|
307
321
|
const fileSlugs = new Set(slugs.keys());
|
|
308
322
|
|
|
@@ -312,8 +326,8 @@ async function collectLintIssues(): Promise<string[]> {
|
|
|
312
326
|
// Parallel read: N small markdown files, ~50 KB each. Bounded by
|
|
313
327
|
// the number of wiki pages, not by CPU.
|
|
314
328
|
const contents = await Promise.all(
|
|
315
|
-
pageFiles.map(async (
|
|
316
|
-
const content = await readTextSafe(path.join(dir,
|
|
329
|
+
pageFiles.map(async (fileName) => {
|
|
330
|
+
const content = await readTextSafe(path.join(dir, fileName));
|
|
317
331
|
return content ?? "";
|
|
318
332
|
}),
|
|
319
333
|
);
|
|
@@ -32,8 +32,8 @@ export interface NotificationDeps {
|
|
|
32
32
|
|
|
33
33
|
let deps: NotificationDeps | null = null;
|
|
34
34
|
|
|
35
|
-
export function initNotifications(
|
|
36
|
-
deps =
|
|
35
|
+
export function initNotifications(injected: NotificationDeps): void {
|
|
36
|
+
deps = injected;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
// ── In-memory store ─────────────────────────────────────────────
|
|
@@ -97,10 +97,10 @@ export function publishNotification(opts: PublishNotificationOpts): void {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
function formatBridgeMessage(
|
|
101
|
-
const icon =
|
|
102
|
-
const parts = [icon,
|
|
103
|
-
if (
|
|
100
|
+
function formatBridgeMessage(payload: NotificationPayload): string {
|
|
101
|
+
const icon = payload.kind === NOTIFICATION_KINDS.agent ? "\u2705" : "\u{1F514}";
|
|
102
|
+
const parts = [icon, payload.title];
|
|
103
|
+
if (payload.body) parts.push(payload.body);
|
|
104
104
|
return parts.join(" ");
|
|
105
105
|
}
|
|
106
106
|
|
|
@@ -13,7 +13,7 @@ export interface IPubSub {
|
|
|
13
13
|
// itself, which is why we switched off raw ws.
|
|
14
14
|
|
|
15
15
|
export function createPubSub(server: http.Server): IPubSub {
|
|
16
|
-
const
|
|
16
|
+
const ioServer = new IOServer(server, {
|
|
17
17
|
path: "/ws/pubsub",
|
|
18
18
|
// Server binds to 127.0.0.1 only, so CORS is moot — but
|
|
19
19
|
// socket.io defaults to rejecting cross-origin upgrade
|
|
@@ -28,7 +28,7 @@ export function createPubSub(server: http.Server): IPubSub {
|
|
|
28
28
|
transports: ["websocket"],
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
ioServer.on("connection", (socket) => {
|
|
32
32
|
socket.on("subscribe", (channel: unknown) => {
|
|
33
33
|
if (typeof channel === "string") socket.join(channel);
|
|
34
34
|
});
|
|
@@ -39,7 +39,7 @@ export function createPubSub(server: http.Server): IPubSub {
|
|
|
39
39
|
|
|
40
40
|
return {
|
|
41
41
|
publish(channel: string, data: unknown): void {
|
|
42
|
-
|
|
42
|
+
ioServer.to(channel).emit("data", { channel, data });
|
|
43
43
|
},
|
|
44
44
|
};
|
|
45
45
|
}
|