diff --git a/data/nodes.db b/data/nodes.db index 0a7a04d..2114f61 100644 Binary files a/data/nodes.db and b/data/nodes.db differ diff --git a/src/templates/template-repository.ts b/src/templates/template-repository.ts index de49a84..f297d21 100644 --- a/src/templates/template-repository.ts +++ b/src/templates/template-repository.ts @@ -3,6 +3,7 @@ import { TemplateWorkflow, TemplateDetail } from './template-fetcher'; import { logger } from '../utils/logger'; import { TemplateSanitizer } from '../utils/template-sanitizer'; import * as zlib from 'zlib'; +import { resolveTemplateNodeTypes } from '../utils/template-node-resolver'; export interface StoredTemplate { id: number; @@ -174,8 +175,16 @@ export class TemplateRepository { * Get templates that use specific node types */ getTemplatesByNodes(nodeTypes: string[], limit: number = 10, offset: number = 0): StoredTemplate[] { + // Resolve input node types to all possible template formats + const resolvedTypes = resolveTemplateNodeTypes(nodeTypes); + + if (resolvedTypes.length === 0) { + logger.debug('No resolved types for template search', { input: nodeTypes }); + return []; + } + // Build query for multiple node types - const conditions = nodeTypes.map(() => "nodes_used LIKE ?").join(" OR "); + const conditions = resolvedTypes.map(() => "nodes_used LIKE ?").join(" OR "); const query = ` SELECT * FROM templates WHERE ${conditions} @@ -183,8 +192,15 @@ export class TemplateRepository { LIMIT ? OFFSET ? `; - const params = [...nodeTypes.map(n => `%"${n}"%`), limit, offset]; + const params = [...resolvedTypes.map(n => `%"${n}"%`), limit, offset]; const results = this.db.prepare(query).all(...params) as StoredTemplate[]; + + logger.debug(`Template search found ${results.length} results`, { + input: nodeTypes, + resolved: resolvedTypes, + found: results.length + }); + return results.map(t => this.decompressWorkflow(t)); } @@ -377,9 +393,16 @@ export class TemplateRepository { * Get count for node templates */ getNodeTemplatesCount(nodeTypes: string[]): number { - const conditions = nodeTypes.map(() => "nodes_used LIKE ?").join(" OR "); + // Resolve input node types to all possible template formats + const resolvedTypes = resolveTemplateNodeTypes(nodeTypes); + + if (resolvedTypes.length === 0) { + return 0; + } + + const conditions = resolvedTypes.map(() => "nodes_used LIKE ?").join(" OR "); const query = `SELECT COUNT(*) as count FROM templates WHERE ${conditions}`; - const params = nodeTypes.map(n => `%"${n}"%`); + const params = resolvedTypes.map(n => `%"${n}"%`); const result = this.db.prepare(query).get(...params) as { count: number }; return result.count; } diff --git a/src/utils/template-node-resolver.ts b/src/utils/template-node-resolver.ts new file mode 100644 index 0000000..648960d --- /dev/null +++ b/src/utils/template-node-resolver.ts @@ -0,0 +1,234 @@ +import { logger } from './logger'; + +/** + * Resolves various node type input formats to all possible template node type formats. + * Templates store node types in full n8n format (e.g., "n8n-nodes-base.slack"). + * This function handles various input formats and expands them to all possible matches. + * + * @param nodeTypes Array of node types in various formats + * @returns Array of all possible template node type formats + * + * @example + * resolveTemplateNodeTypes(['slack']) + * // Returns: ['n8n-nodes-base.slack', 'n8n-nodes-base.slackTrigger'] + * + * resolveTemplateNodeTypes(['nodes-base.webhook']) + * // Returns: ['n8n-nodes-base.webhook'] + * + * resolveTemplateNodeTypes(['httpRequest']) + * // Returns: ['n8n-nodes-base.httpRequest'] + */ +export function resolveTemplateNodeTypes(nodeTypes: string[]): string[] { + const resolvedTypes = new Set(); + + for (const nodeType of nodeTypes) { + // Add all variations for this node type + const variations = generateTemplateNodeVariations(nodeType); + variations.forEach(v => resolvedTypes.add(v)); + } + + const result = Array.from(resolvedTypes); + logger.debug(`Resolved ${nodeTypes.length} input types to ${result.length} template variations`, { + input: nodeTypes, + output: result + }); + + return result; +} + +/** + * Generates all possible template node type variations for a single input. + * + * @param nodeType Single node type in any format + * @returns Array of possible template formats + */ +function generateTemplateNodeVariations(nodeType: string): string[] { + const variations = new Set(); + + // If it's already in full n8n format, just return it + if (nodeType.startsWith('n8n-nodes-base.') || nodeType.startsWith('@n8n/n8n-nodes-langchain.')) { + variations.add(nodeType); + return Array.from(variations); + } + + // Handle partial prefix formats (e.g., "nodes-base.slack" -> "n8n-nodes-base.slack") + if (nodeType.startsWith('nodes-base.')) { + const nodeName = nodeType.replace('nodes-base.', ''); + variations.add(`n8n-nodes-base.${nodeName}`); + // Also try camelCase variations + addCamelCaseVariations(variations, nodeName, 'n8n-nodes-base'); + } else if (nodeType.startsWith('nodes-langchain.')) { + const nodeName = nodeType.replace('nodes-langchain.', ''); + variations.add(`@n8n/n8n-nodes-langchain.${nodeName}`); + // Also try camelCase variations + addCamelCaseVariations(variations, nodeName, '@n8n/n8n-nodes-langchain'); + } else if (!nodeType.includes('.')) { + // Bare node name (e.g., "slack", "webhook", "httpRequest") + // Try both packages with various case combinations + + // For n8n-nodes-base + variations.add(`n8n-nodes-base.${nodeType}`); + addCamelCaseVariations(variations, nodeType, 'n8n-nodes-base'); + + // For langchain (less common for bare names, but include for completeness) + variations.add(`@n8n/n8n-nodes-langchain.${nodeType}`); + addCamelCaseVariations(variations, nodeType, '@n8n/n8n-nodes-langchain'); + + // Add common related node types (e.g., "slack" -> also include "slackTrigger") + addRelatedNodeTypes(variations, nodeType); + } + + return Array.from(variations); +} + +/** + * Adds camelCase variations for a node name. + * + * @param variations Set to add variations to + * @param nodeName The node name to create variations for + * @param packagePrefix The package prefix to use + */ +function addCamelCaseVariations(variations: Set, nodeName: string, packagePrefix: string): void { + const lowerName = nodeName.toLowerCase(); + + // Common patterns in n8n node names + const patterns = [ + // Pattern: somethingTrigger (e.g., slackTrigger, webhookTrigger) + { suffix: 'trigger', capitalize: true }, + { suffix: 'Trigger', capitalize: false }, + // Pattern: somethingRequest (e.g., httpRequest) + { suffix: 'request', capitalize: true }, + { suffix: 'Request', capitalize: false }, + // Pattern: somethingDatabase (e.g., mysqlDatabase, postgresDatabase) + { suffix: 'database', capitalize: true }, + { suffix: 'Database', capitalize: false }, + // Pattern: somethingSheet/Sheets (e.g., googleSheets) + { suffix: 'sheet', capitalize: true }, + { suffix: 'Sheet', capitalize: false }, + { suffix: 'sheets', capitalize: true }, + { suffix: 'Sheets', capitalize: false }, + ]; + + // Check if the lowercase name matches any pattern + for (const pattern of patterns) { + const lowerSuffix = pattern.suffix.toLowerCase(); + + if (lowerName.endsWith(lowerSuffix)) { + // Name already has the suffix, try different capitalizations + const baseName = lowerName.slice(0, -lowerSuffix.length); + if (baseName) { + if (pattern.capitalize) { + // Capitalize the suffix + const capitalizedSuffix = pattern.suffix.charAt(0).toUpperCase() + pattern.suffix.slice(1).toLowerCase(); + variations.add(`${packagePrefix}.${baseName}${capitalizedSuffix}`); + } else { + // Use the suffix as-is + variations.add(`${packagePrefix}.${baseName}${pattern.suffix}`); + } + } + } else if (!lowerName.includes(lowerSuffix)) { + // Name doesn't have the suffix, try adding it + if (pattern.capitalize) { + const capitalizedSuffix = pattern.suffix.charAt(0).toUpperCase() + pattern.suffix.slice(1).toLowerCase(); + variations.add(`${packagePrefix}.${lowerName}${capitalizedSuffix}`); + } + } + } + + // Handle specific known cases + const specificCases: Record = { + 'http': ['httpRequest'], + 'httprequest': ['httpRequest'], + 'mysql': ['mysql', 'mysqlDatabase'], + 'postgres': ['postgres', 'postgresDatabase'], + 'postgresql': ['postgres', 'postgresDatabase'], + 'mongo': ['mongoDb', 'mongodb'], + 'mongodb': ['mongoDb', 'mongodb'], + 'google': ['googleSheets', 'googleDrive', 'googleCalendar'], + 'googlesheet': ['googleSheets'], + 'googlesheets': ['googleSheets'], + 'microsoft': ['microsoftTeams', 'microsoftExcel', 'microsoftOutlook'], + 'slack': ['slack'], + 'discord': ['discord'], + 'telegram': ['telegram'], + 'webhook': ['webhook'], + 'schedule': ['scheduleTrigger'], + 'cron': ['cron', 'scheduleTrigger'], + 'email': ['emailSend', 'emailReadImap', 'gmail'], + 'gmail': ['gmail', 'gmailTrigger'], + 'code': ['code'], + 'javascript': ['code'], + 'python': ['code'], + 'js': ['code'], + 'set': ['set'], + 'if': ['if'], + 'switch': ['switch'], + 'merge': ['merge'], + 'loop': ['splitInBatches'], + 'split': ['splitInBatches', 'splitOut'], + 'ai': ['openAi'], + 'openai': ['openAi'], + 'chatgpt': ['openAi'], + 'gpt': ['openAi'], + 'api': ['httpRequest', 'graphql', 'webhook'], + 'csv': ['spreadsheetFile', 'readBinaryFile'], + 'excel': ['microsoftExcel', 'spreadsheetFile'], + 'spreadsheet': ['spreadsheetFile', 'googleSheets', 'microsoftExcel'], + }; + + const cases = specificCases[lowerName]; + if (cases) { + cases.forEach(c => variations.add(`${packagePrefix}.${c}`)); + } +} + +/** + * Adds related node types for common patterns. + * For example, "slack" should also include "slackTrigger". + * + * @param variations Set to add variations to + * @param nodeName The base node name + */ +function addRelatedNodeTypes(variations: Set, nodeName: string): void { + const lowerName = nodeName.toLowerCase(); + + // Map of base names to their related node types + const relatedTypes: Record = { + 'slack': ['slack', 'slackTrigger'], + 'gmail': ['gmail', 'gmailTrigger'], + 'telegram': ['telegram', 'telegramTrigger'], + 'discord': ['discord', 'discordTrigger'], + 'webhook': ['webhook', 'webhookTrigger'], + 'http': ['httpRequest', 'webhook'], + 'email': ['emailSend', 'emailReadImap', 'gmail', 'gmailTrigger'], + 'google': ['googleSheets', 'googleDrive', 'googleCalendar', 'googleDocs'], + 'microsoft': ['microsoftTeams', 'microsoftExcel', 'microsoftOutlook', 'microsoftOneDrive'], + 'database': ['postgres', 'mysql', 'mongoDb', 'redis', 'postgresDatabase', 'mysqlDatabase'], + 'db': ['postgres', 'mysql', 'mongoDb', 'redis'], + 'sql': ['postgres', 'mysql', 'mssql'], + 'nosql': ['mongoDb', 'redis', 'couchDb'], + 'schedule': ['scheduleTrigger', 'cron'], + 'time': ['scheduleTrigger', 'cron', 'wait'], + 'file': ['readBinaryFile', 'writeBinaryFile', 'moveBinaryFile'], + 'binary': ['readBinaryFile', 'writeBinaryFile', 'moveBinaryFile'], + 'csv': ['spreadsheetFile', 'readBinaryFile'], + 'excel': ['microsoftExcel', 'spreadsheetFile'], + 'json': ['code', 'set'], + 'transform': ['code', 'set', 'merge', 'splitInBatches'], + 'ai': ['openAi', 'agent', 'lmChatOpenAi', 'lmChatAnthropic'], + 'llm': ['openAi', 'agent', 'lmChatOpenAi', 'lmChatAnthropic', 'lmChatGoogleGemini'], + 'agent': ['agent', 'toolAgent'], + 'chat': ['chatTrigger', 'agent'], + }; + + const related = relatedTypes[lowerName]; + if (related) { + related.forEach(r => { + variations.add(`n8n-nodes-base.${r}`); + // Also check if it might be a langchain node + if (['agent', 'toolAgent', 'chatTrigger', 'lmChatOpenAi', 'lmChatAnthropic', 'lmChatGoogleGemini'].includes(r)) { + variations.add(`@n8n/n8n-nodes-langchain.${r}`); + } + }); + } +} \ No newline at end of file diff --git a/tests/unit/utils/template-node-resolver.test.ts b/tests/unit/utils/template-node-resolver.test.ts new file mode 100644 index 0000000..f98c40f --- /dev/null +++ b/tests/unit/utils/template-node-resolver.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from 'vitest'; +import { resolveTemplateNodeTypes } from '../../../src/utils/template-node-resolver'; + +describe('Template Node Resolver', () => { + describe('resolveTemplateNodeTypes', () => { + it('should handle bare node names', () => { + const result = resolveTemplateNodeTypes(['slack']); + + expect(result).toContain('n8n-nodes-base.slack'); + expect(result).toContain('n8n-nodes-base.slackTrigger'); + }); + + it('should handle HTTP variations', () => { + const result = resolveTemplateNodeTypes(['http']); + + expect(result).toContain('n8n-nodes-base.httpRequest'); + expect(result).toContain('n8n-nodes-base.webhook'); + }); + + it('should handle httpRequest variations', () => { + const result = resolveTemplateNodeTypes(['httprequest']); + + expect(result).toContain('n8n-nodes-base.httpRequest'); + }); + + it('should handle partial prefix formats', () => { + const result = resolveTemplateNodeTypes(['nodes-base.webhook']); + + expect(result).toContain('n8n-nodes-base.webhook'); + expect(result).not.toContain('nodes-base.webhook'); + }); + + it('should handle langchain nodes', () => { + const result = resolveTemplateNodeTypes(['nodes-langchain.agent']); + + expect(result).toContain('@n8n/n8n-nodes-langchain.agent'); + expect(result).not.toContain('nodes-langchain.agent'); + }); + + it('should handle already correct formats', () => { + const input = ['n8n-nodes-base.slack', '@n8n/n8n-nodes-langchain.agent']; + const result = resolveTemplateNodeTypes(input); + + expect(result).toContain('n8n-nodes-base.slack'); + expect(result).toContain('@n8n/n8n-nodes-langchain.agent'); + }); + + it('should handle Google services', () => { + const result = resolveTemplateNodeTypes(['google']); + + expect(result).toContain('n8n-nodes-base.googleSheets'); + expect(result).toContain('n8n-nodes-base.googleDrive'); + expect(result).toContain('n8n-nodes-base.googleCalendar'); + }); + + it('should handle database variations', () => { + const result = resolveTemplateNodeTypes(['database']); + + expect(result).toContain('n8n-nodes-base.postgres'); + expect(result).toContain('n8n-nodes-base.mysql'); + expect(result).toContain('n8n-nodes-base.mongoDb'); + expect(result).toContain('n8n-nodes-base.postgresDatabase'); + expect(result).toContain('n8n-nodes-base.mysqlDatabase'); + }); + + it('should handle AI/LLM variations', () => { + const result = resolveTemplateNodeTypes(['ai']); + + expect(result).toContain('n8n-nodes-base.openAi'); + expect(result).toContain('@n8n/n8n-nodes-langchain.agent'); + expect(result).toContain('@n8n/n8n-nodes-langchain.lmChatOpenAi'); + }); + + it('should handle email variations', () => { + const result = resolveTemplateNodeTypes(['email']); + + expect(result).toContain('n8n-nodes-base.emailSend'); + expect(result).toContain('n8n-nodes-base.emailReadImap'); + expect(result).toContain('n8n-nodes-base.gmail'); + expect(result).toContain('n8n-nodes-base.gmailTrigger'); + }); + + it('should handle schedule/cron variations', () => { + const result = resolveTemplateNodeTypes(['schedule']); + + expect(result).toContain('n8n-nodes-base.scheduleTrigger'); + expect(result).toContain('n8n-nodes-base.cron'); + }); + + it('should handle multiple inputs', () => { + const result = resolveTemplateNodeTypes(['slack', 'webhook', 'http']); + + expect(result).toContain('n8n-nodes-base.slack'); + expect(result).toContain('n8n-nodes-base.slackTrigger'); + expect(result).toContain('n8n-nodes-base.webhook'); + expect(result).toContain('n8n-nodes-base.httpRequest'); + }); + + it('should not duplicate entries', () => { + const result = resolveTemplateNodeTypes(['slack', 'n8n-nodes-base.slack']); + + const slackCount = result.filter(r => r === 'n8n-nodes-base.slack').length; + expect(slackCount).toBe(1); + }); + + it('should handle mixed case inputs', () => { + const result = resolveTemplateNodeTypes(['Slack', 'WEBHOOK', 'HttpRequest']); + + expect(result).toContain('n8n-nodes-base.slack'); + expect(result).toContain('n8n-nodes-base.webhook'); + expect(result).toContain('n8n-nodes-base.httpRequest'); + }); + + it('should handle common misspellings', () => { + const result = resolveTemplateNodeTypes(['postgres', 'postgresql']); + + expect(result).toContain('n8n-nodes-base.postgres'); + expect(result).toContain('n8n-nodes-base.postgresDatabase'); + }); + + it('should handle code/javascript/python variations', () => { + const result = resolveTemplateNodeTypes(['javascript', 'python', 'js']); + + result.forEach(() => { + expect(result).toContain('n8n-nodes-base.code'); + }); + }); + + it('should handle trigger suffix variations', () => { + const result = resolveTemplateNodeTypes(['slacktrigger', 'gmailtrigger']); + + expect(result).toContain('n8n-nodes-base.slackTrigger'); + expect(result).toContain('n8n-nodes-base.gmailTrigger'); + }); + + it('should handle sheet/sheets variations', () => { + const result = resolveTemplateNodeTypes(['googlesheet', 'googlesheets']); + + result.forEach(() => { + expect(result).toContain('n8n-nodes-base.googleSheets'); + }); + }); + + it('should return empty array for empty input', () => { + const result = resolveTemplateNodeTypes([]); + + expect(result).toEqual([]); + }); + }); + + describe('Edge cases', () => { + it('should handle undefined-like strings gracefully', () => { + const result = resolveTemplateNodeTypes(['undefined', 'null', '']); + + // Should process them as regular strings + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + }); + + it('should handle very long node names', () => { + const longName = 'a'.repeat(100); + const result = resolveTemplateNodeTypes([longName]); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + }); + + it('should handle special characters in node names', () => { + const result = resolveTemplateNodeTypes(['node-with-dashes', 'node_with_underscores']); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + }); + }); + + describe('Real-world scenarios from AI agents', () => { + it('should handle common AI agent queries', () => { + // These are actual queries that AI agents commonly try + const testCases = [ + { input: ['slack'], shouldContain: 'n8n-nodes-base.slack' }, + { input: ['webhook'], shouldContain: 'n8n-nodes-base.webhook' }, + { input: ['http'], shouldContain: 'n8n-nodes-base.httpRequest' }, + { input: ['email'], shouldContain: 'n8n-nodes-base.gmail' }, + { input: ['gpt'], shouldContain: 'n8n-nodes-base.openAi' }, + { input: ['chatgpt'], shouldContain: 'n8n-nodes-base.openAi' }, + { input: ['agent'], shouldContain: '@n8n/n8n-nodes-langchain.agent' }, + { input: ['sql'], shouldContain: 'n8n-nodes-base.postgres' }, + { input: ['api'], shouldContain: 'n8n-nodes-base.httpRequest' }, + { input: ['csv'], shouldContain: 'n8n-nodes-base.spreadsheetFile' }, + ]; + + testCases.forEach(({ input, shouldContain }) => { + const result = resolveTemplateNodeTypes(input); + expect(result).toContain(shouldContain); + }); + }); + }); +}); \ No newline at end of file