@brianli/kimaki 0.4.72-brianli.1
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.js +2 -0
- package/dist/ai-tool-to-genai.js +233 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/ai-tool.js +6 -0
- package/dist/bin.js +87 -0
- package/dist/bot-token.js +121 -0
- package/dist/bot-token.test.js +134 -0
- package/dist/channel-management.js +101 -0
- package/dist/cli-parsing.test.js +89 -0
- package/dist/cli.js +2529 -0
- package/dist/commands/abort.js +82 -0
- package/dist/commands/action-buttons.js +257 -0
- package/dist/commands/add-project.js +114 -0
- package/dist/commands/agent.js +291 -0
- package/dist/commands/ask-question.js +223 -0
- package/dist/commands/compact.js +120 -0
- package/dist/commands/context-usage.js +140 -0
- package/dist/commands/create-new-project.js +118 -0
- package/dist/commands/diff.js +128 -0
- package/dist/commands/file-upload.js +275 -0
- package/dist/commands/fork.js +217 -0
- package/dist/commands/gemini-apikey.js +70 -0
- package/dist/commands/login.js +490 -0
- package/dist/commands/mention-mode.js +51 -0
- package/dist/commands/merge-worktree.js +124 -0
- package/dist/commands/model.js +694 -0
- package/dist/commands/permissions.js +163 -0
- package/dist/commands/queue.js +217 -0
- package/dist/commands/remove-project.js +115 -0
- package/dist/commands/restart-opencode-server.js +116 -0
- package/dist/commands/resume.js +159 -0
- package/dist/commands/run-command.js +79 -0
- package/dist/commands/session-id.js +78 -0
- package/dist/commands/session.js +192 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +159 -0
- package/dist/commands/unset-model.js +152 -0
- package/dist/commands/upgrade.js +42 -0
- package/dist/commands/user-command.js +148 -0
- package/dist/commands/verbosity.js +60 -0
- package/dist/commands/worktree-settings.js +50 -0
- package/dist/commands/worktree.js +299 -0
- package/dist/condense-memory.js +33 -0
- package/dist/config.js +110 -0
- package/dist/database.js +1050 -0
- package/dist/db.js +159 -0
- package/dist/db.test.js +49 -0
- package/dist/discord-api.js +28 -0
- package/dist/discord-auth.js +231 -0
- package/dist/discord-auth.test.js +80 -0
- package/dist/discord-bot.js +997 -0
- package/dist/discord-utils.js +560 -0
- package/dist/discord-utils.test.js +115 -0
- package/dist/errors.js +167 -0
- package/dist/escape-backticks.test.js +429 -0
- package/dist/format-tables.js +122 -0
- package/dist/format-tables.test.js +199 -0
- package/dist/forum-sync/config.js +79 -0
- package/dist/forum-sync/discord-operations.js +154 -0
- package/dist/forum-sync/index.js +5 -0
- package/dist/forum-sync/markdown.js +117 -0
- package/dist/forum-sync/sync-to-discord.js +417 -0
- package/dist/forum-sync/sync-to-files.js +190 -0
- package/dist/forum-sync/types.js +53 -0
- package/dist/forum-sync/watchers.js +307 -0
- package/dist/gateway-consumer.js +232 -0
- package/dist/gateway-consumer.test.js +18 -0
- package/dist/genai-worker-wrapper.js +111 -0
- package/dist/genai-worker.js +311 -0
- package/dist/genai.js +232 -0
- package/dist/generated/browser.js +17 -0
- package/dist/generated/client.js +35 -0
- package/dist/generated/commonInputTypes.js +10 -0
- package/dist/generated/enums.js +30 -0
- package/dist/generated/internal/class.js +41 -0
- package/dist/generated/internal/prismaNamespace.js +239 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
- package/dist/generated/models/bot_api_keys.js +1 -0
- package/dist/generated/models/bot_tokens.js +1 -0
- package/dist/generated/models/channel_agents.js +1 -0
- package/dist/generated/models/channel_directories.js +1 -0
- package/dist/generated/models/channel_mention_mode.js +1 -0
- package/dist/generated/models/channel_models.js +1 -0
- package/dist/generated/models/channel_verbosity.js +1 -0
- package/dist/generated/models/channel_worktrees.js +1 -0
- package/dist/generated/models/forum_sync_configs.js +1 -0
- package/dist/generated/models/global_models.js +1 -0
- package/dist/generated/models/ipc_requests.js +1 -0
- package/dist/generated/models/part_messages.js +1 -0
- package/dist/generated/models/scheduled_tasks.js +1 -0
- package/dist/generated/models/session_agents.js +1 -0
- package/dist/generated/models/session_models.js +1 -0
- package/dist/generated/models/session_start_sources.js +1 -0
- package/dist/generated/models/thread_sessions.js +1 -0
- package/dist/generated/models/thread_worktrees.js +1 -0
- package/dist/generated/models.js +1 -0
- package/dist/heap-monitor.js +95 -0
- package/dist/hrana-server.js +416 -0
- package/dist/hrana-server.test.js +368 -0
- package/dist/image-utils.js +112 -0
- package/dist/interaction-handler.js +327 -0
- package/dist/ipc-polling.js +251 -0
- package/dist/kimaki-digital-twin.e2e.test.js +165 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +160 -0
- package/dist/markdown.js +342 -0
- package/dist/markdown.test.js +253 -0
- package/dist/message-formatting.js +433 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/openai-realtime.js +228 -0
- package/dist/opencode-plugin-loading.e2e.test.js +91 -0
- package/dist/opencode-plugin.js +536 -0
- package/dist/opencode-plugin.test.js +98 -0
- package/dist/opencode.js +409 -0
- package/dist/privacy-sanitizer.js +105 -0
- package/dist/runtime-mode.js +51 -0
- package/dist/runtime-mode.test.js +115 -0
- package/dist/sentry.js +127 -0
- package/dist/session-handler/state.js +151 -0
- package/dist/session-handler.js +1874 -0
- package/dist/session-search.js +100 -0
- package/dist/session-search.test.js +40 -0
- package/dist/startup-service.js +153 -0
- package/dist/system-message.js +499 -0
- package/dist/task-runner.js +282 -0
- package/dist/task-schedule.js +191 -0
- package/dist/task-schedule.test.js +71 -0
- package/dist/thinking-utils.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +781 -0
- package/dist/tools.js +359 -0
- package/dist/unnest-code-blocks.js +136 -0
- package/dist/unnest-code-blocks.test.js +641 -0
- package/dist/upgrade.js +114 -0
- package/dist/utils.js +109 -0
- package/dist/voice-handler.js +606 -0
- package/dist/voice.js +304 -0
- package/dist/voice.test.js +187 -0
- package/dist/wait-session.js +94 -0
- package/dist/worker-types.js +4 -0
- package/dist/worktree-utils.js +727 -0
- package/dist/xml.js +92 -0
- package/dist/xml.test.js +32 -0
- package/package.json +82 -0
- package/schema.prisma +246 -0
- package/skills/batch/SKILL.md +87 -0
- package/skills/critique/SKILL.md +129 -0
- package/skills/errore/SKILL.md +589 -0
- package/skills/goke/.prettierrc +5 -0
- package/skills/goke/CHANGELOG.md +40 -0
- package/skills/goke/LICENSE +21 -0
- package/skills/goke/README.md +666 -0
- package/skills/goke/SKILL.md +458 -0
- package/skills/goke/package.json +43 -0
- package/skills/goke/src/__test__/coerce.test.ts +411 -0
- package/skills/goke/src/__test__/index.test.ts +1798 -0
- package/skills/goke/src/__test__/types.test-d.ts +111 -0
- package/skills/goke/src/coerce.ts +547 -0
- package/skills/goke/src/goke.ts +1362 -0
- package/skills/goke/src/index.ts +16 -0
- package/skills/goke/src/mri.ts +164 -0
- package/skills/goke/tsconfig.json +15 -0
- package/skills/jitter/EDITOR.md +219 -0
- package/skills/jitter/EXPORT-INTERNALS.md +309 -0
- package/skills/jitter/SKILL.md +158 -0
- package/skills/jitter/jitter-clipboard.json +1042 -0
- package/skills/jitter/package.json +14 -0
- package/skills/jitter/tsconfig.json +15 -0
- package/skills/jitter/utils/actions.ts +212 -0
- package/skills/jitter/utils/export.ts +114 -0
- package/skills/jitter/utils/index.ts +141 -0
- package/skills/jitter/utils/snapshot.ts +154 -0
- package/skills/jitter/utils/traverse.ts +246 -0
- package/skills/jitter/utils/types.ts +279 -0
- package/skills/jitter/utils/wait.ts +133 -0
- package/skills/playwriter/SKILL.md +31 -0
- package/skills/security-review/SKILL.md +208 -0
- package/skills/simplify/SKILL.md +58 -0
- package/skills/termcast/SKILL.md +945 -0
- package/skills/tuistory/SKILL.md +250 -0
- package/skills/zustand-centralized-state/SKILL.md +582 -0
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +41 -0
- package/src/__snapshots__/first-session-no-info.md +17 -0
- package/src/__snapshots__/first-session-with-info.md +23 -0
- package/src/__snapshots__/session-1.md +17 -0
- package/src/__snapshots__/session-2.md +5871 -0
- package/src/__snapshots__/session-3.md +17 -0
- package/src/__snapshots__/session-with-tools.md +5871 -0
- package/src/ai-tool-to-genai.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +282 -0
- package/src/ai-tool.ts +39 -0
- package/src/bin.ts +108 -0
- package/src/bot-token.test.ts +171 -0
- package/src/bot-token.ts +159 -0
- package/src/channel-management.ts +172 -0
- package/src/cli-parsing.test.ts +132 -0
- package/src/cli.ts +3605 -0
- package/src/commands/abort.ts +112 -0
- package/src/commands/action-buttons.ts +376 -0
- package/src/commands/add-project.ts +152 -0
- package/src/commands/agent.ts +404 -0
- package/src/commands/ask-question.ts +330 -0
- package/src/commands/compact.ts +157 -0
- package/src/commands/context-usage.ts +199 -0
- package/src/commands/create-new-project.ts +179 -0
- package/src/commands/diff.ts +165 -0
- package/src/commands/file-upload.ts +389 -0
- package/src/commands/fork.ts +320 -0
- package/src/commands/gemini-apikey.ts +104 -0
- package/src/commands/login.ts +634 -0
- package/src/commands/mention-mode.ts +77 -0
- package/src/commands/merge-worktree.ts +177 -0
- package/src/commands/model.ts +961 -0
- package/src/commands/permissions.ts +261 -0
- package/src/commands/queue.ts +296 -0
- package/src/commands/remove-project.ts +155 -0
- package/src/commands/restart-opencode-server.ts +162 -0
- package/src/commands/resume.ts +242 -0
- package/src/commands/run-command.ts +123 -0
- package/src/commands/session-id.ts +109 -0
- package/src/commands/session.ts +250 -0
- package/src/commands/share.ts +106 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +221 -0
- package/src/commands/unset-model.ts +189 -0
- package/src/commands/upgrade.ts +52 -0
- package/src/commands/user-command.ts +193 -0
- package/src/commands/verbosity.ts +88 -0
- package/src/commands/worktree-settings.ts +79 -0
- package/src/commands/worktree.ts +431 -0
- package/src/condense-memory.ts +36 -0
- package/src/config.ts +148 -0
- package/src/database.ts +1530 -0
- package/src/db.test.ts +60 -0
- package/src/db.ts +190 -0
- package/src/discord-api.ts +35 -0
- package/src/discord-bot.ts +1316 -0
- package/src/discord-utils.test.ts +132 -0
- package/src/discord-utils.ts +767 -0
- package/src/errors.ts +213 -0
- package/src/escape-backticks.test.ts +469 -0
- package/src/format-tables.test.ts +223 -0
- package/src/format-tables.ts +145 -0
- package/src/forum-sync/config.ts +92 -0
- package/src/forum-sync/discord-operations.ts +241 -0
- package/src/forum-sync/index.ts +9 -0
- package/src/forum-sync/markdown.ts +176 -0
- package/src/forum-sync/sync-to-discord.ts +595 -0
- package/src/forum-sync/sync-to-files.ts +294 -0
- package/src/forum-sync/types.ts +175 -0
- package/src/forum-sync/watchers.ts +454 -0
- package/src/genai-worker-wrapper.ts +164 -0
- package/src/genai-worker.ts +386 -0
- package/src/genai.ts +321 -0
- package/src/generated/browser.ts +109 -0
- package/src/generated/client.ts +131 -0
- package/src/generated/commonInputTypes.ts +512 -0
- package/src/generated/enums.ts +46 -0
- package/src/generated/internal/class.ts +362 -0
- package/src/generated/internal/prismaNamespace.ts +2251 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
- package/src/generated/models/bot_api_keys.ts +1288 -0
- package/src/generated/models/bot_tokens.ts +1577 -0
- package/src/generated/models/channel_agents.ts +1256 -0
- package/src/generated/models/channel_directories.ts +2104 -0
- package/src/generated/models/channel_mention_mode.ts +1300 -0
- package/src/generated/models/channel_models.ts +1288 -0
- package/src/generated/models/channel_verbosity.ts +1224 -0
- package/src/generated/models/channel_worktrees.ts +1308 -0
- package/src/generated/models/forum_sync_configs.ts +1452 -0
- package/src/generated/models/global_models.ts +1288 -0
- package/src/generated/models/ipc_requests.ts +1485 -0
- package/src/generated/models/part_messages.ts +1302 -0
- package/src/generated/models/scheduled_tasks.ts +2320 -0
- package/src/generated/models/session_agents.ts +1086 -0
- package/src/generated/models/session_models.ts +1114 -0
- package/src/generated/models/session_start_sources.ts +1408 -0
- package/src/generated/models/thread_sessions.ts +1599 -0
- package/src/generated/models/thread_worktrees.ts +1352 -0
- package/src/generated/models.ts +29 -0
- package/src/heap-monitor.ts +121 -0
- package/src/hrana-server.test.ts +428 -0
- package/src/hrana-server.ts +547 -0
- package/src/image-utils.ts +149 -0
- package/src/interaction-handler.ts +461 -0
- package/src/ipc-polling.ts +325 -0
- package/src/kimaki-digital-twin.e2e.test.ts +201 -0
- package/src/limit-heading-depth.test.ts +116 -0
- package/src/limit-heading-depth.ts +26 -0
- package/src/logger.ts +203 -0
- package/src/markdown.test.ts +360 -0
- package/src/markdown.ts +410 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +549 -0
- package/src/openai-realtime.ts +362 -0
- package/src/opencode-plugin-loading.e2e.test.ts +112 -0
- package/src/opencode-plugin.test.ts +108 -0
- package/src/opencode-plugin.ts +652 -0
- package/src/opencode.ts +554 -0
- package/src/privacy-sanitizer.ts +142 -0
- package/src/schema.sql +158 -0
- package/src/sentry.ts +137 -0
- package/src/session-handler/state.ts +232 -0
- package/src/session-handler.ts +2668 -0
- package/src/session-search.test.ts +50 -0
- package/src/session-search.ts +148 -0
- package/src/startup-service.ts +200 -0
- package/src/system-message.ts +568 -0
- package/src/task-runner.ts +425 -0
- package/src/task-schedule.test.ts +84 -0
- package/src/task-schedule.ts +287 -0
- package/src/thinking-utils.ts +61 -0
- package/src/thread-message-queue.e2e.test.ts +997 -0
- package/src/tools.ts +432 -0
- package/src/unnest-code-blocks.test.ts +679 -0
- package/src/unnest-code-blocks.ts +168 -0
- package/src/upgrade.ts +127 -0
- package/src/utils.ts +145 -0
- package/src/voice-handler.ts +852 -0
- package/src/voice.test.ts +219 -0
- package/src/voice.ts +444 -0
- package/src/wait-session.ts +147 -0
- package/src/worker-types.ts +64 -0
- package/src/worktree-utils.ts +988 -0
- package/src/xml.test.ts +38 -0
- package/src/xml.ts +121 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { test, expect, describe } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
splitTablesFromMarkdown,
|
|
4
|
+
buildTableComponents,
|
|
5
|
+
type ContentSegment,
|
|
6
|
+
} from './format-tables.js'
|
|
7
|
+
import { Lexer, type Tokens } from 'marked'
|
|
8
|
+
|
|
9
|
+
function parseTable(markdown: string): Tokens.Table {
|
|
10
|
+
const lexer = new Lexer()
|
|
11
|
+
const tokens = lexer.lex(markdown)
|
|
12
|
+
return tokens.find((t) => t.type === 'table') as Tokens.Table
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Extract the first container's children from buildTableComponents result */
|
|
16
|
+
function getContainerChildren(
|
|
17
|
+
segments: ContentSegment[],
|
|
18
|
+
): { type: number; content?: string; divider?: boolean; spacing?: number }[] {
|
|
19
|
+
const seg = segments[0]!
|
|
20
|
+
if (seg.type !== 'components') {
|
|
21
|
+
throw new Error('Expected components segment')
|
|
22
|
+
}
|
|
23
|
+
const container = seg.components[0] as { type: number; components: unknown[] }
|
|
24
|
+
return container.components as {
|
|
25
|
+
type: number
|
|
26
|
+
content?: string
|
|
27
|
+
divider?: boolean
|
|
28
|
+
spacing?: number
|
|
29
|
+
}[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('buildTableComponents', () => {
|
|
33
|
+
test('builds container with key-value TextDisplays', () => {
|
|
34
|
+
const table = parseTable(`| Name | Age |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| Alice | 30 |
|
|
37
|
+
| Bob | 25 |`)
|
|
38
|
+
const result = buildTableComponents(table)
|
|
39
|
+
expect(result).toMatchInlineSnapshot(`
|
|
40
|
+
[
|
|
41
|
+
{
|
|
42
|
+
"components": [
|
|
43
|
+
{
|
|
44
|
+
"components": [
|
|
45
|
+
{
|
|
46
|
+
"content": "**Name** Alice
|
|
47
|
+
**Age** 30",
|
|
48
|
+
"type": 10,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"divider": true,
|
|
52
|
+
"spacing": 1,
|
|
53
|
+
"type": 14,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"content": "**Name** Bob
|
|
57
|
+
**Age** 25",
|
|
58
|
+
"type": 10,
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
"type": 17,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
"type": "components",
|
|
65
|
+
},
|
|
66
|
+
]
|
|
67
|
+
`)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('adds separators between row groups', () => {
|
|
71
|
+
const table = parseTable(`| Key | Value |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| a | 1 |
|
|
74
|
+
| b | 2 |
|
|
75
|
+
| c | 3 |`)
|
|
76
|
+
const result = buildTableComponents(table)
|
|
77
|
+
const types = getContainerChildren(result).map((c) => c.type)
|
|
78
|
+
// type 10 = TextDisplay, type 14 = Separator
|
|
79
|
+
expect(types).toMatchInlineSnapshot(`
|
|
80
|
+
[
|
|
81
|
+
10,
|
|
82
|
+
14,
|
|
83
|
+
10,
|
|
84
|
+
14,
|
|
85
|
+
10,
|
|
86
|
+
]
|
|
87
|
+
`)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('single-row table has one TextDisplay, no separators', () => {
|
|
91
|
+
const table = parseTable(`| Method | Endpoint |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| GET | /api/users |`)
|
|
94
|
+
const result = buildTableComponents(table)
|
|
95
|
+
const children = getContainerChildren(result)
|
|
96
|
+
expect(children).toHaveLength(1)
|
|
97
|
+
expect(children[0]!.type).toBe(10)
|
|
98
|
+
expect(children[0]!.content).toMatchInlineSnapshot(`
|
|
99
|
+
"**Method** GET
|
|
100
|
+
**Endpoint** /api/users"
|
|
101
|
+
`)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('splits large table into multiple container segments', () => {
|
|
105
|
+
// 25 rows: exceeds 19 rows per container, so splits into 2 containers
|
|
106
|
+
const headers = '| A | B |'
|
|
107
|
+
const sep = '| --- | --- |'
|
|
108
|
+
const rows = Array.from({ length: 25 }, (_, i) => {
|
|
109
|
+
return `| ${i}a | ${i}b |`
|
|
110
|
+
}).join('\n')
|
|
111
|
+
const table = parseTable(`${headers}\n${sep}\n${rows}`)
|
|
112
|
+
const result = buildTableComponents(table)
|
|
113
|
+
expect(result).toHaveLength(2)
|
|
114
|
+
expect(result[0]!.type).toBe('components')
|
|
115
|
+
expect(result[1]!.type).toBe('components')
|
|
116
|
+
// First container has 19 rows (19 TDs + 18 seps = 37 children)
|
|
117
|
+
const firstChildren = getContainerChildren([result[0]!])
|
|
118
|
+
expect(firstChildren).toHaveLength(19 + 18)
|
|
119
|
+
// Second container has 6 rows (6 TDs + 5 seps = 11 children)
|
|
120
|
+
const secondChildren = getContainerChildren([result[1]!])
|
|
121
|
+
expect(secondChildren).toHaveLength(6 + 5)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('strips formatting from cells', () => {
|
|
125
|
+
const table = parseTable(`| Header | Value |
|
|
126
|
+
| --- | --- |
|
|
127
|
+
| **Bold text** | Normal |
|
|
128
|
+
| *Italic* | \`code\` |`)
|
|
129
|
+
const result = buildTableComponents(table)
|
|
130
|
+
const children = getContainerChildren(result)
|
|
131
|
+
expect(children[0]!.content).toMatchInlineSnapshot(`
|
|
132
|
+
"**Header** Bold text
|
|
133
|
+
**Value** Normal"
|
|
134
|
+
`)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('splitTablesFromMarkdown', () => {
|
|
139
|
+
test('returns single text segment for content without tables', () => {
|
|
140
|
+
const result = splitTablesFromMarkdown('Just some text.\n\nMore text.')
|
|
141
|
+
expect(result).toHaveLength(1)
|
|
142
|
+
expect(result[0]!.type).toBe('text')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('returns single components segment for table-only content', () => {
|
|
146
|
+
const result = splitTablesFromMarkdown(`| A | B |
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| 1 | 2 |`)
|
|
149
|
+
expect(result).toHaveLength(1)
|
|
150
|
+
expect(result[0]!.type).toBe('components')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('splits text before and after table into separate segments', () => {
|
|
154
|
+
const result = splitTablesFromMarkdown(`Text before.
|
|
155
|
+
|
|
156
|
+
| Key | Value |
|
|
157
|
+
| --- | --- |
|
|
158
|
+
| a | 1 |
|
|
159
|
+
|
|
160
|
+
Text after.`)
|
|
161
|
+
expect(result).toHaveLength(3)
|
|
162
|
+
expect(result[0]!.type).toBe('text')
|
|
163
|
+
expect(result[1]!.type).toBe('components')
|
|
164
|
+
expect(result[2]!.type).toBe('text')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('handles multiple tables with text between', () => {
|
|
168
|
+
const result = splitTablesFromMarkdown(`First table:
|
|
169
|
+
|
|
170
|
+
| A | B |
|
|
171
|
+
| --- | --- |
|
|
172
|
+
| 1 | 2 |
|
|
173
|
+
|
|
174
|
+
Middle text.
|
|
175
|
+
|
|
176
|
+
| X | Y |
|
|
177
|
+
| --- | --- |
|
|
178
|
+
| a | b |`)
|
|
179
|
+
expect(result).toHaveLength(4)
|
|
180
|
+
expect(result.map((s) => s.type)).toMatchInlineSnapshot(`
|
|
181
|
+
[
|
|
182
|
+
"text",
|
|
183
|
+
"components",
|
|
184
|
+
"text",
|
|
185
|
+
"components",
|
|
186
|
+
]
|
|
187
|
+
`)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('splits oversized table into multiple component segments', () => {
|
|
191
|
+
const headers = '| A | B |'
|
|
192
|
+
const sep = '| --- | --- |'
|
|
193
|
+
const rows = Array.from({ length: 25 }, (_, i) => {
|
|
194
|
+
return `| ${i}a | ${i}b |`
|
|
195
|
+
}).join('\n')
|
|
196
|
+
const result = splitTablesFromMarkdown(`${headers}\n${sep}\n${rows}`)
|
|
197
|
+
// 25 rows splits into 2 container segments
|
|
198
|
+
expect(result).toHaveLength(2)
|
|
199
|
+
expect(result.every((s) => s.type === 'components')).toBe(true)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('preserves code blocks alongside tables', () => {
|
|
203
|
+
const result = splitTablesFromMarkdown(`Some code:
|
|
204
|
+
|
|
205
|
+
\`\`\`js
|
|
206
|
+
const x = 1
|
|
207
|
+
\`\`\`
|
|
208
|
+
|
|
209
|
+
| Key | Value |
|
|
210
|
+
| --- | --- |
|
|
211
|
+
| a | 1 |
|
|
212
|
+
|
|
213
|
+
Done.`)
|
|
214
|
+
const types = result.map((s) => s.type)
|
|
215
|
+
expect(types).toMatchInlineSnapshot(`
|
|
216
|
+
[
|
|
217
|
+
"text",
|
|
218
|
+
"components",
|
|
219
|
+
"text",
|
|
220
|
+
]
|
|
221
|
+
`)
|
|
222
|
+
})
|
|
223
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Markdown table formatter for Discord.
|
|
2
|
+
// Converts GFM tables to Discord Components V2 (ContainerBuilder with TextDisplay
|
|
3
|
+
// key-value pairs and Separators between row groups). Large tables are split
|
|
4
|
+
// across multiple Container components to stay within the 40-component limit.
|
|
5
|
+
|
|
6
|
+
import { Lexer, type Token, type Tokens } from 'marked'
|
|
7
|
+
import {
|
|
8
|
+
SeparatorSpacingSize,
|
|
9
|
+
type APIContainerComponent,
|
|
10
|
+
type APITextDisplayComponent,
|
|
11
|
+
type APISeparatorComponent,
|
|
12
|
+
type APIMessageTopLevelComponent,
|
|
13
|
+
} from 'discord.js'
|
|
14
|
+
|
|
15
|
+
export type ContentSegment =
|
|
16
|
+
| { type: 'text'; text: string }
|
|
17
|
+
| { type: 'components'; components: APIMessageTopLevelComponent[] }
|
|
18
|
+
|
|
19
|
+
// Max 40 components per message (nested components count toward the limit).
|
|
20
|
+
// Each container uses: 1 (container) + M (TextDisplays) + M-1 (separators) = 2M children.
|
|
21
|
+
// So max rows per container = floor((40 - 1) / 2) = 19.
|
|
22
|
+
const MAX_COMPONENTS = 40
|
|
23
|
+
const MAX_ROWS_PER_CONTAINER = Math.floor((MAX_COMPONENTS - 1) / 2)
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Split markdown into text and table component segments.
|
|
27
|
+
* Tables are rendered as CV2 Container components with bold key-value TextDisplay
|
|
28
|
+
* pairs. Large tables are split across multiple component segments.
|
|
29
|
+
*/
|
|
30
|
+
export function splitTablesFromMarkdown(markdown: string): ContentSegment[] {
|
|
31
|
+
const lexer = new Lexer()
|
|
32
|
+
const tokens = lexer.lex(markdown)
|
|
33
|
+
const segments: ContentSegment[] = []
|
|
34
|
+
let textBuffer = ''
|
|
35
|
+
|
|
36
|
+
for (const token of tokens) {
|
|
37
|
+
if (token.type === 'table') {
|
|
38
|
+
if (textBuffer.trim()) {
|
|
39
|
+
segments.push({ type: 'text', text: textBuffer })
|
|
40
|
+
textBuffer = ''
|
|
41
|
+
}
|
|
42
|
+
const componentSegments = buildTableComponents(token as Tokens.Table)
|
|
43
|
+
segments.push(...componentSegments)
|
|
44
|
+
} else {
|
|
45
|
+
textBuffer += token.raw
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (textBuffer.trim()) {
|
|
50
|
+
segments.push({ type: 'text', text: textBuffer })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return segments
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build CV2 components for a table. Each data row becomes a single TextDisplay
|
|
58
|
+
* with all key-value pairs joined by newlines (header bold as key). Separator
|
|
59
|
+
* dividers are placed between row groups.
|
|
60
|
+
* Large tables are split into multiple component segments, each containing a
|
|
61
|
+
* Container with up to MAX_ROWS_PER_CONTAINER rows.
|
|
62
|
+
*/
|
|
63
|
+
export function buildTableComponents(table: Tokens.Table): ContentSegment[] {
|
|
64
|
+
const headers = table.header.map((cell) => {
|
|
65
|
+
return extractCellText(cell.tokens)
|
|
66
|
+
})
|
|
67
|
+
const rows = table.rows.map((row) => {
|
|
68
|
+
return row.map((cell) => {
|
|
69
|
+
return extractCellText(cell.tokens)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Split rows into chunks that fit within the component limit
|
|
74
|
+
const chunks: string[][][] = []
|
|
75
|
+
for (let i = 0; i < rows.length; i += MAX_ROWS_PER_CONTAINER) {
|
|
76
|
+
chunks.push(rows.slice(i, i + MAX_ROWS_PER_CONTAINER))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return chunks.map((chunkRows) => {
|
|
80
|
+
const children: (APITextDisplayComponent | APISeparatorComponent)[] = []
|
|
81
|
+
|
|
82
|
+
for (let i = 0; i < chunkRows.length; i++) {
|
|
83
|
+
if (i > 0) {
|
|
84
|
+
children.push({
|
|
85
|
+
type: 14,
|
|
86
|
+
divider: true,
|
|
87
|
+
spacing: SeparatorSpacingSize.Small,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
const row = chunkRows[i]!
|
|
91
|
+
const lines = headers.map((key, j) => {
|
|
92
|
+
const value = row[j] || ''
|
|
93
|
+
return `**${key}** ${value}`
|
|
94
|
+
})
|
|
95
|
+
children.push({ type: 10, content: lines.join('\n') })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const container: APIContainerComponent = {
|
|
99
|
+
type: 17,
|
|
100
|
+
components: children,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
type: 'components' as const,
|
|
105
|
+
components: [container] as APIMessageTopLevelComponent[],
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function extractCellText(tokens: Token[]): string {
|
|
111
|
+
const parts: string[] = []
|
|
112
|
+
for (const token of tokens) {
|
|
113
|
+
parts.push(extractTokenText(token))
|
|
114
|
+
}
|
|
115
|
+
return parts.join('').trim()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function extractTokenText(token: Token): string {
|
|
119
|
+
switch (token.type) {
|
|
120
|
+
case 'text':
|
|
121
|
+
case 'codespan':
|
|
122
|
+
case 'escape':
|
|
123
|
+
return token.text
|
|
124
|
+
case 'link':
|
|
125
|
+
return token.href
|
|
126
|
+
case 'image':
|
|
127
|
+
return token.href
|
|
128
|
+
case 'strong':
|
|
129
|
+
case 'em':
|
|
130
|
+
case 'del':
|
|
131
|
+
return token.tokens ? extractCellText(token.tokens) : token.text
|
|
132
|
+
case 'br':
|
|
133
|
+
return ' '
|
|
134
|
+
default: {
|
|
135
|
+
const tokenAny = token as { tokens?: Token[]; text?: string }
|
|
136
|
+
if (tokenAny.tokens && Array.isArray(tokenAny.tokens)) {
|
|
137
|
+
return extractCellText(tokenAny.tokens)
|
|
138
|
+
}
|
|
139
|
+
if (typeof tokenAny.text === 'string') {
|
|
140
|
+
return tokenAny.text
|
|
141
|
+
}
|
|
142
|
+
return ''
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Forum sync configuration from SQLite database.
|
|
2
|
+
// Reads forum_sync_configs table and resolves relative output dirs.
|
|
3
|
+
// On first run, migrates any existing forum-sync.json into the DB.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import yaml from 'js-yaml'
|
|
8
|
+
import { getDataDir } from '../config.js'
|
|
9
|
+
import { getForumSyncConfigs, upsertForumSyncConfig } from '../database.js'
|
|
10
|
+
import { createLogger } from '../logger.js'
|
|
11
|
+
import type { ForumSyncDirection, LoadedForumConfig } from './types.js'
|
|
12
|
+
|
|
13
|
+
const forumLogger = createLogger('FORUM')
|
|
14
|
+
|
|
15
|
+
const LEGACY_CONFIG_FILE = 'forum-sync.json'
|
|
16
|
+
|
|
17
|
+
function isForumSyncDirection(value: unknown): value is ForumSyncDirection {
|
|
18
|
+
return value === 'discord-to-files' || value === 'bidirectional'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolveOutputDir(outputDir: string): string {
|
|
22
|
+
if (path.isAbsolute(outputDir)) return outputDir
|
|
23
|
+
return path.resolve(getDataDir(), outputDir)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One-time migration: if the legacy forum-sync.json exists, import its entries
|
|
28
|
+
* into the DB and rename the file so it's not re-imported on next startup.
|
|
29
|
+
*/
|
|
30
|
+
async function migrateLegacyConfig({ appId }: { appId: string }) {
|
|
31
|
+
const configPath = path.join(getDataDir(), LEGACY_CONFIG_FILE)
|
|
32
|
+
if (!fs.existsSync(configPath)) return
|
|
33
|
+
|
|
34
|
+
forumLogger.log(`Migrating legacy ${LEGACY_CONFIG_FILE} into database...`)
|
|
35
|
+
|
|
36
|
+
const raw = fs.readFileSync(configPath, 'utf8')
|
|
37
|
+
let parsed: unknown
|
|
38
|
+
try {
|
|
39
|
+
parsed = yaml.load(raw)
|
|
40
|
+
} catch {
|
|
41
|
+
forumLogger.warn(
|
|
42
|
+
`Failed to parse legacy ${LEGACY_CONFIG_FILE}, skipping migration`,
|
|
43
|
+
)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!parsed || typeof parsed !== 'object') return
|
|
48
|
+
const forums = (parsed as Record<string, unknown>).forums
|
|
49
|
+
if (!Array.isArray(forums)) return
|
|
50
|
+
|
|
51
|
+
for (const item of forums) {
|
|
52
|
+
if (!item || typeof item !== 'object') continue
|
|
53
|
+
const entry = item as Record<string, unknown>
|
|
54
|
+
const forumChannelId =
|
|
55
|
+
typeof entry.forumChannelId === 'string' ? entry.forumChannelId : ''
|
|
56
|
+
const outputDir = typeof entry.outputDir === 'string' ? entry.outputDir : ''
|
|
57
|
+
const direction = isForumSyncDirection(entry.direction)
|
|
58
|
+
? entry.direction
|
|
59
|
+
: 'bidirectional'
|
|
60
|
+
if (!forumChannelId || !outputDir) continue
|
|
61
|
+
|
|
62
|
+
await upsertForumSyncConfig({
|
|
63
|
+
appId,
|
|
64
|
+
forumChannelId,
|
|
65
|
+
outputDir: resolveOutputDir(outputDir),
|
|
66
|
+
direction,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Rename so we don't re-import next time
|
|
71
|
+
const backupPath = configPath + '.migrated'
|
|
72
|
+
fs.renameSync(configPath, backupPath)
|
|
73
|
+
forumLogger.log(
|
|
74
|
+
`Legacy config migrated and renamed to ${path.basename(backupPath)}`,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function readForumSyncConfig({ appId }: { appId?: string }) {
|
|
79
|
+
if (!appId) return []
|
|
80
|
+
|
|
81
|
+
// Migrate legacy JSON file on first run
|
|
82
|
+
await migrateLegacyConfig({ appId })
|
|
83
|
+
|
|
84
|
+
const rows = await getForumSyncConfigs({ appId })
|
|
85
|
+
return rows.map<LoadedForumConfig>((row) => ({
|
|
86
|
+
forumChannelId: row.forumChannelId,
|
|
87
|
+
outputDir: resolveOutputDir(row.outputDir),
|
|
88
|
+
direction: isForumSyncDirection(row.direction)
|
|
89
|
+
? row.direction
|
|
90
|
+
: 'bidirectional',
|
|
91
|
+
}))
|
|
92
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Discord API operations for forum sync.
|
|
2
|
+
// Resolves forum channels, fetches threads (active + archived) with pagination,
|
|
3
|
+
// fetches thread messages, loads existing forum files from disk, and ensures directories.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import {
|
|
8
|
+
ChannelType,
|
|
9
|
+
type Client,
|
|
10
|
+
type ForumChannel,
|
|
11
|
+
type Message,
|
|
12
|
+
type ThreadChannel,
|
|
13
|
+
} from 'discord.js'
|
|
14
|
+
import { createLogger } from '../logger.js'
|
|
15
|
+
import { parseFrontmatter, getStringValue } from './markdown.js'
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_RATE_LIMIT_DELAY_MS,
|
|
18
|
+
ForumChannelResolveError,
|
|
19
|
+
ForumSyncOperationError,
|
|
20
|
+
delay,
|
|
21
|
+
type ExistingForumFile,
|
|
22
|
+
} from './types.js'
|
|
23
|
+
|
|
24
|
+
const forumLogger = createLogger('FORUM')
|
|
25
|
+
|
|
26
|
+
export function getCanonicalThreadFilePath({
|
|
27
|
+
outputDir,
|
|
28
|
+
threadId,
|
|
29
|
+
subfolder,
|
|
30
|
+
}: {
|
|
31
|
+
outputDir: string
|
|
32
|
+
threadId: string
|
|
33
|
+
subfolder?: string
|
|
34
|
+
}) {
|
|
35
|
+
if (subfolder) {
|
|
36
|
+
return path.join(outputDir, subfolder, `${threadId}.md`)
|
|
37
|
+
}
|
|
38
|
+
return path.join(outputDir, `${threadId}.md`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function ensureDirectory({ directory }: { directory: string }) {
|
|
42
|
+
const result = await fs.promises.mkdir(directory, { recursive: true }).catch(
|
|
43
|
+
(cause) =>
|
|
44
|
+
new ForumSyncOperationError({
|
|
45
|
+
forumChannelId: 'unknown',
|
|
46
|
+
reason: directory,
|
|
47
|
+
cause,
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
if (result instanceof Error) return result
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function resolveForumChannel({
|
|
54
|
+
discordClient,
|
|
55
|
+
forumChannelId,
|
|
56
|
+
}: {
|
|
57
|
+
discordClient: Client
|
|
58
|
+
forumChannelId: string
|
|
59
|
+
}): Promise<ForumChannel | ForumChannelResolveError> {
|
|
60
|
+
const channel = await discordClient.channels
|
|
61
|
+
.fetch(forumChannelId)
|
|
62
|
+
.catch((cause) => new ForumChannelResolveError({ forumChannelId, cause }))
|
|
63
|
+
if (channel instanceof Error) return channel
|
|
64
|
+
|
|
65
|
+
if (!channel || channel.type !== ChannelType.GuildForum) {
|
|
66
|
+
return new ForumChannelResolveError({ forumChannelId })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return channel
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function fetchForumThreads({
|
|
73
|
+
forumChannel,
|
|
74
|
+
}: {
|
|
75
|
+
forumChannel: ForumChannel
|
|
76
|
+
}): Promise<ThreadChannel[] | ForumSyncOperationError> {
|
|
77
|
+
const byId = new Map<string, ThreadChannel>()
|
|
78
|
+
|
|
79
|
+
const active = await forumChannel.threads.fetchActive().catch(
|
|
80
|
+
(cause) =>
|
|
81
|
+
new ForumSyncOperationError({
|
|
82
|
+
forumChannelId: forumChannel.id,
|
|
83
|
+
reason: 'fetchActive failed',
|
|
84
|
+
cause,
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
if (active instanceof Error) return active
|
|
88
|
+
|
|
89
|
+
for (const [id, thread] of active.threads) {
|
|
90
|
+
byId.set(id, thread)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let before: Date | undefined
|
|
94
|
+
while (true) {
|
|
95
|
+
const archived = await forumChannel.threads
|
|
96
|
+
.fetchArchived({ type: 'public', limit: 100, before })
|
|
97
|
+
.catch(
|
|
98
|
+
(cause) =>
|
|
99
|
+
new ForumSyncOperationError({
|
|
100
|
+
forumChannelId: forumChannel.id,
|
|
101
|
+
reason: 'fetchArchived failed',
|
|
102
|
+
cause,
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
105
|
+
if (archived instanceof Error) return archived
|
|
106
|
+
|
|
107
|
+
const threads = Array.from(archived.threads.values())
|
|
108
|
+
for (const thread of threads) {
|
|
109
|
+
byId.set(thread.id, thread)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!archived.hasMore || threads.length === 0) break
|
|
113
|
+
|
|
114
|
+
const timestamps = threads
|
|
115
|
+
.map((thread) => thread.archiveTimestamp ?? thread.createdTimestamp)
|
|
116
|
+
.filter((value): value is number => value !== null)
|
|
117
|
+
|
|
118
|
+
const oldestTimestamp = Math.min(...timestamps)
|
|
119
|
+
if (!Number.isFinite(oldestTimestamp)) break
|
|
120
|
+
|
|
121
|
+
before = new Date(oldestTimestamp - 1)
|
|
122
|
+
await delay({ ms: DEFAULT_RATE_LIMIT_DELAY_MS })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return Array.from(byId.values())
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function fetchThreadMessages({
|
|
129
|
+
thread,
|
|
130
|
+
}: {
|
|
131
|
+
thread: ThreadChannel
|
|
132
|
+
}): Promise<Message[] | ForumSyncOperationError> {
|
|
133
|
+
const byId = new Map<string, Message>()
|
|
134
|
+
let before: string | undefined
|
|
135
|
+
|
|
136
|
+
while (true) {
|
|
137
|
+
const fetched = await thread.messages.fetch({ limit: 100, before }).catch(
|
|
138
|
+
(cause) =>
|
|
139
|
+
new ForumSyncOperationError({
|
|
140
|
+
forumChannelId: thread.parentId || 'unknown',
|
|
141
|
+
reason: `message fetch failed for thread ${thread.id}`,
|
|
142
|
+
cause,
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
if (fetched instanceof Error) return fetched
|
|
146
|
+
|
|
147
|
+
const messages = Array.from(fetched.values())
|
|
148
|
+
for (const message of messages) {
|
|
149
|
+
byId.set(message.id, message)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (messages.length < 100 || messages.length === 0) break
|
|
153
|
+
|
|
154
|
+
// Find oldest message for cursor - messages are sorted by Discord, last is oldest
|
|
155
|
+
const oldest = messages[messages.length - 1]
|
|
156
|
+
if (!oldest) break
|
|
157
|
+
|
|
158
|
+
before = oldest.id
|
|
159
|
+
await delay({ ms: DEFAULT_RATE_LIMIT_DELAY_MS })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return Array.from(byId.values()).sort(
|
|
163
|
+
(a, b) => a.createdTimestamp - b.createdTimestamp,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Recursively walks a directory collecting all .md files with their relative subfolder path.
|
|
169
|
+
*/
|
|
170
|
+
async function collectMarkdownFiles({
|
|
171
|
+
dir,
|
|
172
|
+
outputDir,
|
|
173
|
+
}: {
|
|
174
|
+
dir: string
|
|
175
|
+
outputDir: string
|
|
176
|
+
}): Promise<Array<{ filePath: string; subfolder?: string }>> {
|
|
177
|
+
if (!fs.existsSync(dir)) return []
|
|
178
|
+
|
|
179
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true })
|
|
180
|
+
const relativeSub = path.relative(outputDir, dir)
|
|
181
|
+
const subfolder = relativeSub && relativeSub !== '.' ? relativeSub : undefined
|
|
182
|
+
|
|
183
|
+
const mdFiles: Array<{ filePath: string; subfolder?: string }> = entries
|
|
184
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
185
|
+
.map((entry) => ({ filePath: path.join(dir, entry.name), subfolder }))
|
|
186
|
+
|
|
187
|
+
const subdirs = entries.filter((entry) => entry.isDirectory())
|
|
188
|
+
const nestedResults = await Promise.all(
|
|
189
|
+
subdirs.map((subdir) =>
|
|
190
|
+
collectMarkdownFiles({
|
|
191
|
+
dir: path.join(dir, subdir.name),
|
|
192
|
+
outputDir,
|
|
193
|
+
}),
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return [...mdFiles, ...nestedResults.flat()]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function loadExistingForumFiles({
|
|
201
|
+
outputDir,
|
|
202
|
+
}: {
|
|
203
|
+
outputDir: string
|
|
204
|
+
}): Promise<ExistingForumFile[]> {
|
|
205
|
+
const markdownEntries = await collectMarkdownFiles({
|
|
206
|
+
dir: outputDir,
|
|
207
|
+
outputDir,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const loaded = await Promise.all(
|
|
211
|
+
markdownEntries.map(async ({ filePath, subfolder }) => {
|
|
212
|
+
const content = await fs.promises
|
|
213
|
+
.readFile(filePath, 'utf8')
|
|
214
|
+
.catch((cause) => {
|
|
215
|
+
forumLogger.warn(`Failed to read forum file ${filePath}:`, cause)
|
|
216
|
+
return null
|
|
217
|
+
})
|
|
218
|
+
if (content === null) return null
|
|
219
|
+
|
|
220
|
+
const parsed = parseFrontmatter({ markdown: content })
|
|
221
|
+
const threadIdFromFrontmatter = getStringValue({
|
|
222
|
+
value: parsed.frontmatter.threadId,
|
|
223
|
+
})
|
|
224
|
+
const threadIdFromFilename = path.basename(filePath, '.md')
|
|
225
|
+
const threadId =
|
|
226
|
+
threadIdFromFrontmatter ||
|
|
227
|
+
(/^\d+$/.test(threadIdFromFilename) ? threadIdFromFilename : '')
|
|
228
|
+
if (!threadId) return null
|
|
229
|
+
|
|
230
|
+
const result: ExistingForumFile = {
|
|
231
|
+
filePath,
|
|
232
|
+
threadId,
|
|
233
|
+
frontmatter: parsed.frontmatter,
|
|
234
|
+
subfolder,
|
|
235
|
+
}
|
|
236
|
+
return result
|
|
237
|
+
}),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
return loaded.filter((item): item is ExistingForumFile => item !== null)
|
|
241
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Forum sync module entry point.
|
|
2
|
+
// Re-exports the public API for forum <-> markdown synchronization.
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
startConfiguredForumSync,
|
|
6
|
+
stopConfiguredForumSync,
|
|
7
|
+
} from './watchers.js'
|
|
8
|
+
export { syncForumToFiles } from './sync-to-files.js'
|
|
9
|
+
export { syncFilesToForum } from './sync-to-discord.js'
|