disunday 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/ai-tool-to-genai.js +208 -0
  2. package/dist/ai-tool-to-genai.test.js +267 -0
  3. package/dist/channel-management.js +96 -0
  4. package/dist/cli.js +1674 -0
  5. package/dist/commands/abort.js +89 -0
  6. package/dist/commands/add-project.js +117 -0
  7. package/dist/commands/agent.js +250 -0
  8. package/dist/commands/ask-question.js +219 -0
  9. package/dist/commands/compact.js +126 -0
  10. package/dist/commands/context-menu.js +171 -0
  11. package/dist/commands/context.js +89 -0
  12. package/dist/commands/cost.js +93 -0
  13. package/dist/commands/create-new-project.js +111 -0
  14. package/dist/commands/diff.js +77 -0
  15. package/dist/commands/export.js +100 -0
  16. package/dist/commands/files.js +73 -0
  17. package/dist/commands/fork.js +199 -0
  18. package/dist/commands/help.js +54 -0
  19. package/dist/commands/login.js +488 -0
  20. package/dist/commands/merge-worktree.js +165 -0
  21. package/dist/commands/model.js +325 -0
  22. package/dist/commands/permissions.js +140 -0
  23. package/dist/commands/ping.js +13 -0
  24. package/dist/commands/queue.js +133 -0
  25. package/dist/commands/remove-project.js +119 -0
  26. package/dist/commands/rename.js +70 -0
  27. package/dist/commands/restart-opencode-server.js +77 -0
  28. package/dist/commands/resume.js +276 -0
  29. package/dist/commands/run-config.js +79 -0
  30. package/dist/commands/run.js +240 -0
  31. package/dist/commands/schedule.js +170 -0
  32. package/dist/commands/session-info.js +58 -0
  33. package/dist/commands/session.js +191 -0
  34. package/dist/commands/settings.js +84 -0
  35. package/dist/commands/share.js +89 -0
  36. package/dist/commands/status.js +79 -0
  37. package/dist/commands/sync.js +119 -0
  38. package/dist/commands/theme.js +53 -0
  39. package/dist/commands/types.js +2 -0
  40. package/dist/commands/undo-redo.js +170 -0
  41. package/dist/commands/user-command.js +135 -0
  42. package/dist/commands/verbosity.js +59 -0
  43. package/dist/commands/worktree-settings.js +50 -0
  44. package/dist/commands/worktree.js +288 -0
  45. package/dist/config.js +139 -0
  46. package/dist/database.js +585 -0
  47. package/dist/discord-bot.js +700 -0
  48. package/dist/discord-utils.js +336 -0
  49. package/dist/discord-utils.test.js +20 -0
  50. package/dist/errors.js +193 -0
  51. package/dist/escape-backticks.test.js +429 -0
  52. package/dist/format-tables.js +96 -0
  53. package/dist/format-tables.test.js +418 -0
  54. package/dist/genai-worker-wrapper.js +109 -0
  55. package/dist/genai-worker.js +299 -0
  56. package/dist/genai.js +230 -0
  57. package/dist/image-utils.js +107 -0
  58. package/dist/interaction-handler.js +289 -0
  59. package/dist/limit-heading-depth.js +25 -0
  60. package/dist/limit-heading-depth.test.js +105 -0
  61. package/dist/logger.js +111 -0
  62. package/dist/markdown.js +323 -0
  63. package/dist/markdown.test.js +269 -0
  64. package/dist/message-formatting.js +447 -0
  65. package/dist/message-formatting.test.js +73 -0
  66. package/dist/openai-realtime.js +226 -0
  67. package/dist/opencode.js +224 -0
  68. package/dist/reaction-handler.js +128 -0
  69. package/dist/scheduler.js +93 -0
  70. package/dist/security.js +200 -0
  71. package/dist/session-handler.js +1436 -0
  72. package/dist/system-message.js +138 -0
  73. package/dist/tools.js +354 -0
  74. package/dist/unnest-code-blocks.js +117 -0
  75. package/dist/unnest-code-blocks.test.js +432 -0
  76. package/dist/utils.js +95 -0
  77. package/dist/voice-handler.js +569 -0
  78. package/dist/voice.js +344 -0
  79. package/dist/worker-types.js +4 -0
  80. package/dist/worktree-utils.js +134 -0
  81. package/dist/xml.js +90 -0
  82. package/dist/xml.test.js +32 -0
  83. package/package.json +84 -0
package/dist/voice.js ADDED
@@ -0,0 +1,344 @@
1
+ // Audio transcription service using Google Gemini.
2
+ // Transcribes voice messages with code-aware context, using grep/glob tools
3
+ // to verify technical terms, filenames, and function names in the codebase.
4
+ // Uses errore for type-safe error handling.
5
+ import { GoogleGenAI, Type } from '@google/genai';
6
+ import * as errore from 'errore';
7
+ import { createLogger, LogPrefix } from './logger.js';
8
+ import { glob } from 'glob';
9
+ import { ripGrep } from 'ripgrep-js';
10
+ import { ApiKeyMissingError, InvalidAudioFormatError, TranscriptionError, EmptyTranscriptionError, NoResponseContentError, NoToolResponseError, GrepSearchError, GlobSearchError, } from './errors.js';
11
+ const voiceLogger = createLogger(LogPrefix.VOICE);
12
+ function runGrep({ pattern, directory }) {
13
+ return errore.tryAsync({
14
+ try: async () => {
15
+ const results = await ripGrep(directory, {
16
+ string: pattern,
17
+ globs: ['!node_modules/**', '!.git/**', '!dist/**', '!build/**'],
18
+ });
19
+ if (results.length === 0) {
20
+ return 'No matches found';
21
+ }
22
+ const output = results
23
+ .slice(0, 10)
24
+ .map((match) => {
25
+ return `${match.path.text}:${match.line_number}: ${match.lines.text.trim()}`;
26
+ })
27
+ .join('\n');
28
+ return output.slice(0, 2000);
29
+ },
30
+ catch: (e) => new GrepSearchError({ pattern, cause: e }),
31
+ });
32
+ }
33
+ function runGlob({ pattern, directory }) {
34
+ return errore.tryAsync({
35
+ try: async () => {
36
+ const files = await glob(pattern, {
37
+ cwd: directory,
38
+ nodir: false,
39
+ ignore: ['node_modules/**', '.git/**', 'dist/**', 'build/**'],
40
+ maxDepth: 10,
41
+ });
42
+ if (files.length === 0) {
43
+ return 'No files found';
44
+ }
45
+ return files.slice(0, 30).join('\n');
46
+ },
47
+ catch: (e) => new GlobSearchError({ pattern, cause: e }),
48
+ });
49
+ }
50
+ const grepToolDeclaration = {
51
+ name: 'grep',
52
+ description: 'Search for a pattern in file contents to verify if a technical term, function name, or variable exists in the code. Use this to check if transcribed words match actual code.',
53
+ parameters: {
54
+ type: Type.OBJECT,
55
+ properties: {
56
+ pattern: {
57
+ type: Type.STRING,
58
+ description: 'The search pattern (case-insensitive). Can be a word, function name, or partial match.',
59
+ },
60
+ },
61
+ required: ['pattern'],
62
+ },
63
+ };
64
+ const globToolDeclaration = {
65
+ name: 'glob',
66
+ description: 'Search for files by name pattern. Use this to verify if a filename or directory mentioned in the audio actually exists in the project.',
67
+ parameters: {
68
+ type: Type.OBJECT,
69
+ properties: {
70
+ pattern: {
71
+ type: Type.STRING,
72
+ description: 'The glob pattern to match files. Examples: "*.ts", "**/*.json", "**/config*", "src/**/*.tsx"',
73
+ },
74
+ },
75
+ required: ['pattern'],
76
+ },
77
+ };
78
+ const transcriptionResultToolDeclaration = {
79
+ name: 'transcriptionResult',
80
+ description: 'MANDATORY: You MUST call this tool to complete the task. This is the ONLY way to return results - text responses are ignored. Call this with your transcription, even if imperfect. An imperfect transcription is better than none.',
81
+ parameters: {
82
+ type: Type.OBJECT,
83
+ properties: {
84
+ transcription: {
85
+ type: Type.STRING,
86
+ description: 'The final transcription of the audio. MUST be non-empty. If audio is unclear, transcribe your best interpretation. If silent, use "[inaudible audio]".',
87
+ },
88
+ },
89
+ required: ['transcription'],
90
+ },
91
+ };
92
+ function createToolRunner({ directory }) {
93
+ const hasDirectory = directory && directory.trim().length > 0;
94
+ return async ({ name, args }) => {
95
+ if (name === 'transcriptionResult') {
96
+ return {
97
+ type: 'result',
98
+ transcription: args?.transcription || '',
99
+ };
100
+ }
101
+ if (name === 'grep' && hasDirectory) {
102
+ const pattern = args?.pattern || '';
103
+ voiceLogger.log(`Grep search: "${pattern}"`);
104
+ const result = await runGrep({ pattern, directory });
105
+ const output = (() => {
106
+ if (result instanceof Error) {
107
+ voiceLogger.error('grep search failed:', result);
108
+ return 'grep search failed';
109
+ }
110
+ return result;
111
+ })();
112
+ voiceLogger.log(`Grep result: ${output.slice(0, 100)}...`);
113
+ return { type: 'toolResponse', name: 'grep', output };
114
+ }
115
+ if (name === 'glob' && hasDirectory) {
116
+ const pattern = args?.pattern || '';
117
+ voiceLogger.log(`Glob search: "${pattern}"`);
118
+ const result = await runGlob({ pattern, directory });
119
+ const output = (() => {
120
+ if (result instanceof Error) {
121
+ voiceLogger.error('glob search failed:', result);
122
+ return 'glob search failed';
123
+ }
124
+ return result;
125
+ })();
126
+ voiceLogger.log(`Glob result: ${output.slice(0, 100)}...`);
127
+ return { type: 'toolResponse', name: 'glob', output };
128
+ }
129
+ return { type: 'skip' };
130
+ };
131
+ }
132
+ export async function runTranscriptionLoop({ genAI, model, initialContents, tools, temperature, toolRunner, maxSteps = 10, }) {
133
+ // Wrap external API call that can throw
134
+ const initialResponse = await errore.tryAsync({
135
+ try: () => genAI.models.generateContent({
136
+ model,
137
+ contents: initialContents,
138
+ config: {
139
+ temperature,
140
+ thinkingConfig: {
141
+ thinkingBudget: 1024,
142
+ },
143
+ tools,
144
+ },
145
+ }),
146
+ catch: (e) => new TranscriptionError({ reason: `API call failed: ${String(e)}`, cause: e }),
147
+ });
148
+ if (initialResponse instanceof Error) {
149
+ return initialResponse;
150
+ }
151
+ let response = initialResponse;
152
+ const conversationHistory = [...initialContents];
153
+ let stepsRemaining = maxSteps;
154
+ while (true) {
155
+ const candidate = response.candidates?.[0];
156
+ if (!candidate?.content?.parts) {
157
+ const text = response.text?.trim();
158
+ if (text) {
159
+ voiceLogger.log(`No parts but got text response: "${text.slice(0, 100)}..."`);
160
+ return text;
161
+ }
162
+ return new NoResponseContentError();
163
+ }
164
+ const functionCalls = candidate.content.parts.filter((part) => 'functionCall' in part && !!part.functionCall);
165
+ if (functionCalls.length === 0) {
166
+ const text = response.text?.trim();
167
+ if (text) {
168
+ voiceLogger.log(`No function calls but got text: "${text.slice(0, 100)}..."`);
169
+ return text;
170
+ }
171
+ return new TranscriptionError({ reason: 'Model did not produce a transcription' });
172
+ }
173
+ conversationHistory.push({
174
+ role: 'model',
175
+ parts: candidate.content.parts,
176
+ });
177
+ const functionResponseParts = [];
178
+ for (const part of functionCalls) {
179
+ const call = part.functionCall;
180
+ const args = call.args;
181
+ const result = await toolRunner({ name: call.name || '', args });
182
+ if (result.type === 'result') {
183
+ const transcription = result.transcription?.trim() || '';
184
+ voiceLogger.log(`Transcription result received: "${transcription.slice(0, 100)}..."`);
185
+ if (!transcription) {
186
+ return new EmptyTranscriptionError();
187
+ }
188
+ return transcription;
189
+ }
190
+ if (result.type === 'toolResponse') {
191
+ stepsRemaining--;
192
+ const stepsWarning = (() => {
193
+ if (stepsRemaining <= 0) {
194
+ return '\n\n[CRITICAL: Tool limit reached. You MUST call transcriptionResult NOW. No more grep/glob allowed. Call transcriptionResult immediately with your best transcription.]';
195
+ }
196
+ if (stepsRemaining === 1) {
197
+ return '\n\n[URGENT: FINAL STEP. You MUST call transcriptionResult NOW. Do NOT call grep or glob. Call transcriptionResult with your transcription immediately.]';
198
+ }
199
+ if (stepsRemaining <= 3) {
200
+ return `\n\n[WARNING: Only ${stepsRemaining} steps remaining. Finish searching soon and call transcriptionResult. Do not wait until the last step.]`;
201
+ }
202
+ return '';
203
+ })();
204
+ functionResponseParts.push({
205
+ functionResponse: {
206
+ name: result.name,
207
+ response: { output: result.output + stepsWarning },
208
+ },
209
+ });
210
+ }
211
+ }
212
+ if (functionResponseParts.length === 0) {
213
+ return new NoToolResponseError();
214
+ }
215
+ conversationHistory.push({
216
+ role: 'user',
217
+ parts: functionResponseParts,
218
+ });
219
+ // Wrap external API call that can throw
220
+ const nextResponse = await errore.tryAsync({
221
+ try: () => genAI.models.generateContent({
222
+ model,
223
+ contents: conversationHistory,
224
+ config: {
225
+ temperature,
226
+ thinkingConfig: {
227
+ thinkingBudget: 512,
228
+ },
229
+ tools: stepsRemaining <= 0
230
+ ? [{ functionDeclarations: [transcriptionResultToolDeclaration] }]
231
+ : tools,
232
+ },
233
+ }),
234
+ catch: (e) => new TranscriptionError({ reason: `API call failed: ${String(e)}`, cause: e }),
235
+ });
236
+ if (nextResponse instanceof Error) {
237
+ return nextResponse;
238
+ }
239
+ response = nextResponse;
240
+ }
241
+ }
242
+ export function transcribeAudio({ audio, prompt, language, temperature, geminiApiKey, directory, currentSessionContext, lastSessionContext, }) {
243
+ const apiKey = geminiApiKey || process.env.GEMINI_API_KEY;
244
+ if (!apiKey) {
245
+ return Promise.resolve(new ApiKeyMissingError({ service: 'Gemini' }));
246
+ }
247
+ const genAI = new GoogleGenAI({ apiKey });
248
+ const audioBase64 = (() => {
249
+ if (typeof audio === 'string') {
250
+ return audio;
251
+ }
252
+ if (audio instanceof Buffer) {
253
+ return audio.toString('base64');
254
+ }
255
+ if (audio instanceof Uint8Array) {
256
+ return Buffer.from(audio).toString('base64');
257
+ }
258
+ if (audio instanceof ArrayBuffer) {
259
+ return Buffer.from(audio).toString('base64');
260
+ }
261
+ return '';
262
+ })();
263
+ if (!audioBase64) {
264
+ return Promise.resolve(new InvalidAudioFormatError());
265
+ }
266
+ const languageHint = language ? `The audio is in ${language}.\n\n` : '';
267
+ // build session context section
268
+ const sessionContextParts = [];
269
+ if (lastSessionContext) {
270
+ sessionContextParts.push(`<last_session>
271
+ ${lastSessionContext}
272
+ </last_session>`);
273
+ }
274
+ if (currentSessionContext) {
275
+ sessionContextParts.push(`<current_session>
276
+ ${currentSessionContext}
277
+ </current_session>`);
278
+ }
279
+ const sessionContextSection = sessionContextParts.length > 0
280
+ ? `\nSession context (use to understand references to files, functions, tools used):\n${sessionContextParts.join('\n\n')}`
281
+ : '';
282
+ const transcriptionPrompt = `${languageHint}Transcribe this audio for a coding agent (like Claude Code or OpenCode).
283
+
284
+ CRITICAL REQUIREMENT: You MUST call the "transcriptionResult" tool to complete this task.
285
+ - The transcriptionResult tool is the ONLY way to return results
286
+ - Text responses are completely ignored - only tool calls work
287
+ - You MUST call transcriptionResult even if you run out of tool calls
288
+ - An imperfect transcription is better than no transcription
289
+ - DO NOT end without calling transcriptionResult
290
+
291
+ This is a software development environment. The speaker is giving instructions to an AI coding assistant. Expect:
292
+ - File paths, function names, CLI commands, package names, API endpoints
293
+
294
+ RULES:
295
+ 1. If audio is unclear, transcribe your best interpretation, interpreting words event with strong accents are present, identifying the accent being used first so you can guess what the words meawn
296
+ 2. If audio seems silent/empty, call transcriptionResult with "[inaudible audio]"
297
+ 3. Use the session context below to understand technical terms, file names, function names mentioned
298
+
299
+ Common corrections (apply without tool calls):
300
+ - "reacked" → "React", "jason" → "JSON", "get hub" → "GitHub", "no JS" → "Node.js", "dacker" → "Docker"
301
+
302
+ Project file structure:
303
+ <file_tree>
304
+ ${prompt}
305
+ </file_tree>
306
+ ${sessionContextSection}
307
+
308
+ REMEMBER: Call "transcriptionResult" tool with your transcription. This is mandatory.
309
+
310
+ Note: "critique" is a CLI tool for showing diffs in the browser.`;
311
+ // const hasDirectory = directory && directory.trim().length > 0
312
+ const tools = [
313
+ {
314
+ functionDeclarations: [
315
+ transcriptionResultToolDeclaration,
316
+ // grep/glob disabled - was causing transcription to hang
317
+ // ...(hasDirectory ? [grepToolDeclaration, globToolDeclaration] : []),
318
+ ],
319
+ },
320
+ ];
321
+ const initialContents = [
322
+ {
323
+ role: 'user',
324
+ parts: [
325
+ { text: transcriptionPrompt },
326
+ {
327
+ inlineData: {
328
+ data: audioBase64,
329
+ mimeType: 'audio/mpeg',
330
+ },
331
+ },
332
+ ],
333
+ },
334
+ ];
335
+ const toolRunner = createToolRunner({ directory });
336
+ return runTranscriptionLoop({
337
+ genAI,
338
+ model: 'gemini-2.5-flash',
339
+ initialContents,
340
+ tools,
341
+ temperature: temperature ?? 0.3,
342
+ toolRunner,
343
+ });
344
+ }
@@ -0,0 +1,4 @@
1
+ // Type definitions for worker thread message passing.
2
+ // Defines the protocol between main thread and GenAI worker for
3
+ // audio streaming, tool calls, and session lifecycle management.
4
+ export {};
@@ -0,0 +1,134 @@
1
+ // Worktree utility functions.
2
+ // Wrapper for OpenCode worktree creation that also initializes git submodules.
3
+ // Also handles capturing and applying git diffs when creating worktrees from threads.
4
+ import { exec, spawn } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { createLogger, LogPrefix } from './logger.js';
7
+ export const execAsync = promisify(exec);
8
+ const logger = createLogger(LogPrefix.WORKTREE);
9
+ /**
10
+ * Create a worktree using OpenCode SDK and initialize git submodules.
11
+ * This wrapper ensures submodules are properly set up in new worktrees.
12
+ *
13
+ * If diff is provided, it's applied BEFORE submodule update to ensure
14
+ * any submodule pointer changes in the diff are respected.
15
+ */
16
+ export async function createWorktreeWithSubmodules({ clientV2, directory, name, diff, }) {
17
+ // 1. Create worktree via OpenCode SDK
18
+ const response = await clientV2.worktree.create({
19
+ directory,
20
+ worktreeCreateInput: { name },
21
+ });
22
+ if (response.error) {
23
+ return new Error(`SDK error: ${JSON.stringify(response.error)}`);
24
+ }
25
+ if (!response.data) {
26
+ return new Error('No worktree data returned from SDK');
27
+ }
28
+ const worktreeDir = response.data.directory;
29
+ let diffApplied = false;
30
+ // 2. Apply diff BEFORE submodule update (if provided)
31
+ // This ensures any submodule pointer changes in the diff are applied first,
32
+ // so submodule update checks out the correct commits.
33
+ if (diff) {
34
+ logger.log(`Applying diff to ${worktreeDir} before submodule init`);
35
+ diffApplied = await applyGitDiff(worktreeDir, diff);
36
+ }
37
+ // 3. Init submodules in new worktree (don't block on failure)
38
+ // Uses --init to initialize, --recursive for nested submodules.
39
+ // Submodules will be checked out at the commit specified by the (possibly updated) index.
40
+ try {
41
+ logger.log(`Initializing submodules in ${worktreeDir}`);
42
+ await execAsync('git submodule update --init --recursive', {
43
+ cwd: worktreeDir,
44
+ });
45
+ logger.log(`Submodules initialized in ${worktreeDir}`);
46
+ }
47
+ catch (e) {
48
+ // Log but don't fail - submodules might not exist
49
+ logger.warn(`Failed to init submodules in ${worktreeDir}: ${e instanceof Error ? e.message : String(e)}`);
50
+ }
51
+ // 4. Install dependencies using ni (detects package manager from lockfile)
52
+ try {
53
+ logger.log(`Installing dependencies in ${worktreeDir}`);
54
+ await execAsync('npx -y ni', {
55
+ cwd: worktreeDir,
56
+ });
57
+ logger.log(`Dependencies installed in ${worktreeDir}`);
58
+ }
59
+ catch (e) {
60
+ // Log but don't fail - might not be a JS project or might fail for various reasons
61
+ logger.warn(`Failed to install dependencies in ${worktreeDir}: ${e instanceof Error ? e.message : String(e)}`);
62
+ }
63
+ return { ...response.data, diffApplied };
64
+ }
65
+ /**
66
+ * Capture git diff from a directory (both staged and unstaged changes).
67
+ * Returns null if no changes or on error.
68
+ */
69
+ export async function captureGitDiff(directory) {
70
+ try {
71
+ // Capture unstaged changes
72
+ const unstagedResult = await execAsync('git diff', { cwd: directory });
73
+ const unstaged = unstagedResult.stdout.trim();
74
+ // Capture staged changes
75
+ const stagedResult = await execAsync('git diff --staged', { cwd: directory });
76
+ const staged = stagedResult.stdout.trim();
77
+ if (!unstaged && !staged) {
78
+ return null;
79
+ }
80
+ return { unstaged, staged };
81
+ }
82
+ catch (e) {
83
+ logger.warn(`Failed to capture git diff from ${directory}: ${e instanceof Error ? e.message : String(e)}`);
84
+ return null;
85
+ }
86
+ }
87
+ /**
88
+ * Run a git command with stdin input.
89
+ * Uses spawn to pipe the diff content to git apply.
90
+ */
91
+ function runGitWithStdin(args, cwd, input) {
92
+ return new Promise((resolve, reject) => {
93
+ const child = spawn('git', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
94
+ let stderr = '';
95
+ child.stderr?.on('data', (data) => {
96
+ stderr += data.toString();
97
+ });
98
+ child.on('close', (code) => {
99
+ if (code === 0) {
100
+ resolve();
101
+ }
102
+ else {
103
+ reject(new Error(stderr || `git ${args.join(' ')} failed with code ${code}`));
104
+ }
105
+ });
106
+ child.on('error', reject);
107
+ child.stdin?.write(input);
108
+ child.stdin?.end();
109
+ });
110
+ }
111
+ /**
112
+ * Apply a captured git diff to a directory.
113
+ * Applies staged changes first, then unstaged.
114
+ */
115
+ export async function applyGitDiff(directory, diff) {
116
+ try {
117
+ // Apply staged changes first (and stage them)
118
+ if (diff.staged) {
119
+ logger.log(`Applying staged diff to ${directory}`);
120
+ await runGitWithStdin(['apply', '--index'], directory, diff.staged);
121
+ }
122
+ // Apply unstaged changes (don't stage them)
123
+ if (diff.unstaged) {
124
+ logger.log(`Applying unstaged diff to ${directory}`);
125
+ await runGitWithStdin(['apply'], directory, diff.unstaged);
126
+ }
127
+ logger.log(`Successfully applied diff to ${directory}`);
128
+ return true;
129
+ }
130
+ catch (e) {
131
+ logger.warn(`Failed to apply git diff to ${directory}: ${e instanceof Error ? e.message : String(e)}`);
132
+ return false;
133
+ }
134
+ }
package/dist/xml.js ADDED
@@ -0,0 +1,90 @@
1
+ // XML/HTML tag content extractor.
2
+ // Parses XML-like tags from strings (e.g., channel topics) to extract
3
+ // Kimaki configuration like directory paths and app IDs.
4
+ import { DomHandler, Parser, ElementType } from 'htmlparser2';
5
+ import { createLogger, LogPrefix } from './logger.js';
6
+ const xmlLogger = createLogger(LogPrefix.XML);
7
+ export function extractTagsArrays({ xml, tags, }) {
8
+ const result = {
9
+ others: [],
10
+ };
11
+ // Initialize arrays for each tag
12
+ tags.forEach((tag) => {
13
+ result[tag] = [];
14
+ });
15
+ try {
16
+ const handler = new DomHandler((error, dom) => {
17
+ if (error) {
18
+ xmlLogger.error('Error parsing XML:', error);
19
+ }
20
+ else {
21
+ const findTags = (nodes, path = []) => {
22
+ nodes.forEach((node) => {
23
+ if (node.type === ElementType.Tag) {
24
+ const element = node;
25
+ const currentPath = [...path, element.name];
26
+ const pathString = currentPath.join('.');
27
+ // Extract content using original string positions
28
+ const extractContent = () => {
29
+ // Use element's own indices but exclude the tags
30
+ if (element.startIndex !== null && element.endIndex !== null) {
31
+ // Extract the full element including tags
32
+ const fullElement = xml.substring(element.startIndex, element.endIndex + 1);
33
+ // Find where content starts (after opening tag)
34
+ const contentStart = fullElement.indexOf('>') + 1;
35
+ // Find where content ends (before this element's closing tag)
36
+ const closingTag = `</${element.name}>`;
37
+ const contentEnd = fullElement.lastIndexOf(closingTag);
38
+ if (contentStart > 0 && contentEnd > contentStart) {
39
+ return fullElement.substring(contentStart, contentEnd);
40
+ }
41
+ return '';
42
+ }
43
+ return '';
44
+ };
45
+ // Check both single tag names and nested paths
46
+ if (tags.includes(element.name)) {
47
+ const content = extractContent();
48
+ result[element.name]?.push(content);
49
+ }
50
+ // Check for nested path matches
51
+ if (tags.includes(pathString)) {
52
+ const content = extractContent();
53
+ result[pathString]?.push(content);
54
+ }
55
+ if (element.children) {
56
+ findTags(element.children, currentPath);
57
+ }
58
+ }
59
+ else if (node.type === ElementType.Text && node.parent?.type === ElementType.Root) {
60
+ const textNode = node;
61
+ if (textNode.data.trim()) {
62
+ // console.log('node.parent',node.parent)
63
+ result.others?.push(textNode.data.trim());
64
+ }
65
+ }
66
+ });
67
+ };
68
+ findTags(dom);
69
+ }
70
+ }, {
71
+ withStartIndices: true,
72
+ withEndIndices: true,
73
+ xmlMode: true,
74
+ });
75
+ const parser = new Parser(handler, {
76
+ xmlMode: true,
77
+ decodeEntities: false,
78
+ });
79
+ parser.write(xml);
80
+ parser.end();
81
+ }
82
+ catch (error) {
83
+ xmlLogger.error('Unexpected error in extractTags:', error);
84
+ }
85
+ return result;
86
+ }
87
+ export function extractNonXmlContent(xml) {
88
+ const result = extractTagsArrays({ xml, tags: [] });
89
+ return result.others.join('\n');
90
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import { extractNonXmlContent } from './xml.js';
3
+ describe('extractNonXmlContent', () => {
4
+ test('removes xml tags and returns only text content', () => {
5
+ const xml = 'Hello <tag>content</tag> world <nested><inner>deep</inner></nested> end';
6
+ expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`
7
+ "Hello
8
+ world
9
+ end"
10
+ `);
11
+ });
12
+ test('handles multiple text segments', () => {
13
+ const xml = 'Start <a>tag1</a> middle <b>tag2</b> finish';
14
+ expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`
15
+ "Start
16
+ middle
17
+ finish"
18
+ `);
19
+ });
20
+ test('handles only xml without text', () => {
21
+ const xml = '<root><child>content</child></root>';
22
+ expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`""`);
23
+ });
24
+ test('handles only text without xml', () => {
25
+ const xml = 'Just plain text';
26
+ expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`"Just plain text"`);
27
+ });
28
+ test('handles empty string', () => {
29
+ const xml = '';
30
+ expect(extractNonXmlContent(xml)).toMatchInlineSnapshot(`""`);
31
+ });
32
+ });