From e7895d2e01d3117d216beec03b26d91c170ff9ed Mon Sep 17 00:00:00 2001 From: czlonkowski <56956555+czlonkowski@users.noreply.github.com> Date: Sun, 14 Sep 2025 15:04:17 +0200 Subject: [PATCH] feat: enhance template tooling with pagination and flexible retrieval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pagination support to all template search/list tools - Consistent response format with total, limit, offset, hasMore - Support for customizable limits (1-100) and offsets - Add new list_templates tool for browsing all templates - Returns minimal data (id, name, views, node count) - Supports sorting by views, created_at, or name - Efficient for discovering available templates - Enhance get_template with flexible response modes - nodes_only: Just list of node types (minimal tokens) - structure: Nodes with positions and connections - full: Complete workflow JSON (default) - Update database_statistics to show template count - Shows total templates, average/min/max views - Provides complete database overview - Add count methods to repository for pagination - getSearchCount, getNodeTemplatesCount, getTaskTemplatesCount - Enables accurate pagination info 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- data/nodes.db | Bin 50679808 -> 50679808 bytes src/mcp/server.ts | 111 ++++++++++++++++++-------- src/mcp/tools.ts | 71 ++++++++++++++++- src/templates/template-repository.ts | 108 +++++++++++++++++++++---- src/templates/template-service.ts | 114 ++++++++++++++++++++++++--- 5 files changed, 339 insertions(+), 65 deletions(-) diff --git a/data/nodes.db b/data/nodes.db index 083819115bf5a94ed7f5b7e72d019c3e712c2d7a..0a7a04de795f6ca2a032f832ac7ceb8594513262 100644 GIT binary patch delta 3754 zcmXZfcUX;m9LMo@4a`nl$G^- z-md45_v`-t?*H%Kb$`QRV;sX&l#_#}!Qk$%d<_PJmBBDEE!xe)=ouZDZ1gd^8i$!( zj0Mbb#sG7)p3Ww3qo3Y7n!`;4jJ{0a))q6YEsV;fm@So+vQ}ACR+UZJsO-vC*{K}L zUgcB{DwoQw@~FHjpUSTsRRL8{IjKU*Srt}Al#6mzMO86XT$NBIRVh_kl~H9?IpwCx zs|u>3a#xj9W#yr&sH&=(s;+7%PgPUZQnghbRaezh^;HAaP&HDGm6vLwnyO~1xoV+W zs#eNdwN`CZTjisCm7i*-{8f9^L3LD}RDkNNx~Q(Io9eCtRS(ru^-{f6AJtd&Q~gzt z8lVQML29rXqK2wrYPbqkA!>vgsY2B#HCl~PW7Rk{UQJLF)g%?BCaWnbTuoKe)O0mN z%~Z40Y!#vAsJUvMid0c5TFqBxwLrzFg=&#ntd^*yYMENDR;ZO~m5NoX)f%-{tyAmO z2DMRbQkzwr+M>3qZECyPp?0cWYPZ^>_NsjVP_^4ynT`Q5{i9Dp?&>De9Oy zu1=_v>XbUI&Zx8MoI0;AsEg{7x~#6KtLmD%u5PHC>Xy2#?x?%!p1Q9dsE6v2daRzP zr|Ow{u3o5@>Xk}Wuhkp%R=rd2)d%%aeNt)avr1PP>WliSzNzo(hssnx)i3p1{ZW4- z^zBC~Fu}j0C6g6cLl(#i*}w*}gDu!W4zP!u-~hQGH{^l5kPq^MBNTvw-~@%h845!Y zZ~<2+3dNu}lz@^@3Q9v6C=2Dl4a!3Ws0i**2`YmJRDr5c4XQ&8@PwLB3u;3hs0;O= zJ~V)a&;3P!^i7z^WIJWPOzFbTq7 zGE9MRm`5A20~5Dy8k9}d7lI0T0w5sp9-B*Rfi zfn#tSPQXbx1*hQ*oP~369xlK|xCEEs3S5P2a2;;IO}GWO;SSt|dvG5fz(aThkKqYC zg=g>_UcgIu1*z~F-oRUU2k+qne1uPs2A?4vGT;k*g>Ud3en2MtgkSI*{=i?Ou0N9o zBbead(UQputRV|zg=}C0*})dw$?RziG6tB7 z8Xe8?#$cnrmb2N#|s*ozIiYOOVR25UjRS8v6l~Scu8C6!5Q{|Pb zs-P;WO3F=DR#lX{s;a7~>dHgaP@bx$s-a4n`uBw~ru6n4Rs+a1m`l!CD zp9)d^)c`e64N`;E5H(Z{Q^VB=HByaIqtzHSR*h5R)dV$Bg{ny^Oifl(RJfX|rm5*_ zhMK8nso5$*%~5mJJQb;;RJ59}%xZy(QL$>FTBH`MC2FZ!rk1M}YNd)(tJG?>My*xr z)Oxi+ZB(07yxOd`sI6+7+OBq}oobibt@fzBDnTWxeQLitpbn}->aaSZlGIUkOdVGz z)Jb(pomOYmS#?gGR~OVpbxB=TSJYK?OVzsL48!8)Mu5hGSnCKRee+6)erSk{Zg6gxB8?0 zM(E#0pgMR!4e*4TPz!299jFWS zpguH!hR_HagBLV`rqB$UgEzE*mf!=epf$7sU+@Ee2!KFn3+*5X+CwmOfR4}!Izt!e z3f-VP^njkw3wlEz=nMTI1p31O7zl%4FbsjAFbsyn2p9>YU^I+@u`mwC!vvTJp)d); zU@}aBaF`0yU^>iznJ^1xLj=r$xiAkRAqt{lKA2$v#6T=8ghj9zmcUY22Fqautb{mN z1*>5Vtc7*39yY*6*aY#g8MeSy*aq8S2keAhup9QkUPypM*a!RJ033uva2Sq25*&qN za2!s+NjL?k;S8LGb8sFmz(u$Om*EOrg==sfZoo~r1-Ic2+=Y8^A09w5JcLK^7@ojW zcm~hm1-yh;@ETI!4WvREyoGo09zMWF_ynIJ9Wvkxe1&iD9e%)1_yw8p8~(swqpm-b zh7nBg|7gjw0&B2=ERYqlL3XeOJIDd{kP{pr7vzRKkQeenesBaQC;-k-5DGzIC;~1} z6pBG{C;=s*6qJTCP!`HTd2odaP!TGD8&rlW;0{%x8dL`lr~#f(6KX+ir~`GO9@K{h z&=49yWAK6|&=i_MbMS^1&=P#06|{yn;0u1>4*?JeZJ`|mL3;>>4$u)gL1*X!U7;Iv zhaS)qdO>gK1AU<%gg}2700UtV42B^v6o$cY7y%<;6pV&3Fc!wac$feaArvM-7)*vK z5Drse8cc^7FcW6MY>0q4Fc;=QBt$_p%m*_pfEb8{g|G+~!xC5u%V0UIfRzvjt6(*( zfwiy>*24za2%8`tHp3R!3fo{i?0}uH3wFaE*b51e2>W0^9Dsvx2oA#$NP?qq435JI zI0>iVG@OC6a1PGH1-J;8;4)l+t8fjj!wt9zx8OG1fxB=I?!yB}hKKM79>WuO3eVs< zynvVR3SL7Byn$3mgSYSw-opp@2%q3Hq(cULfv@llzQYgr3BMo{e#0O5Ytr>+p&zr(gx7RmNWDMhR-9Nl8wk}Rza_8A8Ilq84aUZ!-rL>HHY KG^05s((xZTbZfi- diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 7ce3b25..41a36d6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -725,21 +725,32 @@ export class N8NDocumentationMCPServer { case 'get_node_as_tool_info': this.validateToolParams(name, args, ['nodeType']); return this.getNodeAsToolInfo(args.nodeType); + case 'list_templates': + // No required params + const listLimit = Math.min(Math.max(Number(args.limit) || 10, 1), 100); + const listOffset = Math.max(Number(args.offset) || 0, 0); + const sortBy = args.sortBy || 'views'; + return this.listTemplates(listLimit, listOffset, sortBy); case 'list_node_templates': this.validateToolParams(name, args, ['nodeTypes']); - const templateLimit = args.limit !== undefined ? Number(args.limit) || 10 : 10; - return this.listNodeTemplates(args.nodeTypes, templateLimit); + const templateLimit = Math.min(Math.max(Number(args.limit) || 10, 1), 100); + const templateOffset = Math.max(Number(args.offset) || 0, 0); + return this.listNodeTemplates(args.nodeTypes, templateLimit, templateOffset); case 'get_template': this.validateToolParams(name, args, ['templateId']); const templateId = Number(args.templateId); - return this.getTemplate(templateId); + const mode = args.mode || 'full'; + return this.getTemplate(templateId, mode); case 'search_templates': this.validateToolParams(name, args, ['query']); - const searchLimit = args.limit !== undefined ? Number(args.limit) || 20 : 20; - return this.searchTemplates(args.query, searchLimit); + const searchLimit = Math.min(Math.max(Number(args.limit) || 20, 1), 100); + const searchOffset = Math.max(Number(args.offset) || 0, 0); + return this.searchTemplates(args.query, searchLimit, searchOffset); case 'get_templates_for_task': this.validateToolParams(name, args, ['task']); - return this.getTemplatesForTask(args.task); + const taskLimit = Math.min(Math.max(Number(args.limit) || 10, 1), 100); + const taskOffset = Math.max(Number(args.offset) || 0, 0); + return this.getTemplatesForTask(args.task, taskLimit, taskOffset); case 'validate_workflow': this.validateToolParams(name, args, ['workflow']); return this.validateWorkflow(args.workflow, args.options); @@ -1594,8 +1605,19 @@ Full documentation is being prepared. For now, use get_node_essentials for confi GROUP BY package_name `).all() as any[]; + // Get template statistics + const templateStats = this.db!.prepare(` + SELECT + COUNT(*) as total_templates, + AVG(views) as avg_views, + MIN(views) as min_views, + MAX(views) as max_views + FROM templates + `).get() as any; + return { totalNodes: stats.total, + totalTemplates: templateStats.total_templates || 0, statistics: { aiTools: stats.ai_tools, triggers: stats.triggers, @@ -1604,6 +1626,12 @@ Full documentation is being prepared. For now, use get_node_essentials for confi documentationCoverage: Math.round((stats.with_docs / stats.total) * 100) + '%', uniquePackages: stats.packages, uniqueCategories: stats.categories, + templates: { + total: templateStats.total_templates || 0, + avgViews: Math.round(templateStats.avg_views || 0), + minViews: templateStats.min_views || 0, + maxViews: templateStats.max_views || 0 + } }, packageBreakdown: packages.map(pkg => ({ package: pkg.package_name, @@ -2300,76 +2328,95 @@ Full documentation is being prepared. For now, use get_node_essentials for confi } // Template-related methods - private async listNodeTemplates(nodeTypes: string[], limit: number = 10): Promise { + private async listTemplates(limit: number = 10, offset: number = 0, sortBy: 'views' | 'created_at' | 'name' = 'views'): Promise { await this.ensureInitialized(); if (!this.templateService) throw new Error('Template service not initialized'); - const templates = await this.templateService.listNodeTemplates(nodeTypes, limit); + const result = await this.templateService.listTemplates(limit, offset, sortBy); - if (templates.length === 0) { + return { + ...result, + tip: result.items.length > 0 ? + `Use get_template(templateId) to get full workflow details. Total: ${result.total} templates available.` : + "No templates found. Run 'npm run fetch:templates' to update template database" + }; + } + + private async listNodeTemplates(nodeTypes: string[], limit: number = 10, offset: number = 0): Promise { + await this.ensureInitialized(); + if (!this.templateService) throw new Error('Template service not initialized'); + + const result = await this.templateService.listNodeTemplates(nodeTypes, limit, offset); + + if (result.items.length === 0 && offset === 0) { return { + ...result, message: `No templates found using nodes: ${nodeTypes.join(', ')}`, - tip: "Try searching with more common nodes or run 'npm run fetch:templates' to update template database", - templates: [] + tip: "Try searching with more common nodes or run 'npm run fetch:templates' to update template database" }; } return { - templates, - count: templates.length, - tip: `Use get_template(templateId) to get the full workflow JSON for any template` + ...result, + tip: `Showing ${result.items.length} of ${result.total} templates. Use offset for pagination.` }; } - private async getTemplate(templateId: number): Promise { + private async getTemplate(templateId: number, mode: 'nodes_only' | 'structure' | 'full' = 'full'): Promise { await this.ensureInitialized(); if (!this.templateService) throw new Error('Template service not initialized'); - const template = await this.templateService.getTemplate(templateId); + const template = await this.templateService.getTemplate(templateId, mode); if (!template) { return { error: `Template ${templateId} not found`, - tip: "Use list_node_templates or search_templates to find available templates" + tip: "Use list_templates, list_node_templates or search_templates to find available templates" }; } + const usage = mode === 'nodes_only' ? "Node list for quick overview" : + mode === 'structure' ? "Workflow structure without full details" : + "Complete workflow JSON ready to import into n8n"; + return { + mode, template, - usage: "Import this workflow JSON directly into n8n or use it as a reference for building workflows" + usage }; } - private async searchTemplates(query: string, limit: number = 20): Promise { + private async searchTemplates(query: string, limit: number = 20, offset: number = 0): Promise { await this.ensureInitialized(); if (!this.templateService) throw new Error('Template service not initialized'); - const templates = await this.templateService.searchTemplates(query, limit); + const result = await this.templateService.searchTemplates(query, limit, offset); - if (templates.length === 0) { + if (result.items.length === 0 && offset === 0) { return { + ...result, message: `No templates found matching: "${query}"`, - tip: "Try different keywords or run 'npm run fetch:templates' to update template database", - templates: [] + tip: "Try different keywords or run 'npm run fetch:templates' to update template database" }; } return { - templates, - count: templates.length, - query + ...result, + query, + tip: `Found ${result.total} templates matching "${query}". Showing ${result.items.length}.` }; } - private async getTemplatesForTask(task: string): Promise { + private async getTemplatesForTask(task: string, limit: number = 10, offset: number = 0): Promise { await this.ensureInitialized(); if (!this.templateService) throw new Error('Template service not initialized'); - const templates = await this.templateService.getTemplatesForTask(task); + const result = await this.templateService.getTemplatesForTask(task, limit, offset); const availableTasks = this.templateService.listAvailableTasks(); - if (templates.length === 0) { + if (result.items.length === 0 && offset === 0) { return { + ...result, message: `No templates found for task: ${task}`, availableTasks, tip: "Try a different task or use search_templates for custom searches" @@ -2377,10 +2424,10 @@ Full documentation is being prepared. For now, use get_node_essentials for confi } return { + ...result, task, - templates, - count: templates.length, - description: this.getTaskDescription(task) + description: this.getTaskDescription(task), + tip: `${result.total} templates available for ${task}. Showing ${result.items.length}.` }; } diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 2ed2700..2b62c86 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -323,9 +323,37 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [ required: ['nodeType'], }, }, + { + name: 'list_templates', + description: `List all templates with minimal data (id, name, views, node count). Use for browsing available templates.`, + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Number of results (1-100). Default 10.', + default: 10, + minimum: 1, + maximum: 100, + }, + offset: { + type: 'number', + description: 'Pagination offset. Default 0.', + default: 0, + minimum: 0, + }, + sortBy: { + type: 'string', + enum: ['views', 'created_at', 'name'], + description: 'Sort field. Default: views (popularity).', + default: 'views', + }, + }, + }, + }, { name: 'list_node_templates', - description: `Find templates using specific nodes. 399 community workflows. Use FULL types: "n8n-nodes-base.httpRequest".`, + description: `Find templates using specific nodes. Returns paginated results. Use FULL types: "n8n-nodes-base.httpRequest".`, inputSchema: { type: 'object', properties: { @@ -338,6 +366,14 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [ type: 'number', description: 'Maximum number of templates to return. Default 10.', default: 10, + minimum: 1, + maximum: 100, + }, + offset: { + type: 'number', + description: 'Pagination offset. Default 0.', + default: 0, + minimum: 0, }, }, required: ['nodeTypes'], @@ -345,7 +381,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [ }, { name: 'get_template', - description: `Get complete workflow JSON by ID. Ready to import. IDs from list_node_templates or search_templates.`, + description: `Get template by ID. Use mode to control response size: nodes_only (minimal), structure (nodes+connections), full (complete workflow).`, inputSchema: { type: 'object', properties: { @@ -353,13 +389,19 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [ type: 'number', description: 'The template ID to retrieve', }, + mode: { + type: 'string', + enum: ['nodes_only', 'structure', 'full'], + description: 'Response detail level. nodes_only: just node list, structure: nodes+connections, full: complete workflow JSON.', + default: 'full', + }, }, required: ['templateId'], }, }, { name: 'search_templates', - description: `Search templates by name/description keywords. NOT for node types! For nodes use list_node_templates. Example: "chatbot".`, + description: `Search templates by name/description keywords. Returns paginated results. NOT for node types! For nodes use list_node_templates.`, inputSchema: { type: 'object', properties: { @@ -371,6 +413,14 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [ type: 'number', description: 'Maximum number of results. Default 20.', default: 20, + minimum: 1, + maximum: 100, + }, + offset: { + type: 'number', + description: 'Pagination offset. Default 0.', + default: 0, + minimum: 0, }, }, required: ['query'], @@ -378,7 +428,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [ }, { name: 'get_templates_for_task', - description: `Curated templates by task: ai_automation, data_sync, webhooks, email, slack, data_transform, files, scheduling, api, database.`, + description: `Curated templates by task. Returns paginated results sorted by popularity.`, inputSchema: { type: 'object', properties: { @@ -398,6 +448,19 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [ ], description: 'The type of task to get templates for', }, + limit: { + type: 'number', + description: 'Maximum number of results. Default 10.', + default: 10, + minimum: 1, + maximum: 100, + }, + offset: { + type: 'number', + description: 'Pagination offset. Default 0.', + default: 0, + minimum: 0, + }, }, required: ['task'], }, diff --git a/src/templates/template-repository.ts b/src/templates/template-repository.ts index 0a6baa3..de49a84 100644 --- a/src/templates/template-repository.ts +++ b/src/templates/template-repository.ts @@ -173,17 +173,17 @@ export class TemplateRepository { /** * Get templates that use specific node types */ - getTemplatesByNodes(nodeTypes: string[], limit: number = 10): StoredTemplate[] { + getTemplatesByNodes(nodeTypes: string[], limit: number = 10, offset: number = 0): StoredTemplate[] { // Build query for multiple node types const conditions = nodeTypes.map(() => "nodes_used LIKE ?").join(" OR "); const query = ` SELECT * FROM templates WHERE ${conditions} ORDER BY views DESC, created_at DESC - LIMIT ? + LIMIT ? OFFSET ? `; - const params = [...nodeTypes.map(n => `%"${n}"%`), limit]; + const params = [...nodeTypes.map(n => `%"${n}"%`), limit, offset]; const results = this.db.prepare(query).all(...params) as StoredTemplate[]; return results.map(t => this.decompressWorkflow(t)); } @@ -232,13 +232,13 @@ export class TemplateRepository { /** * Search templates by name or description */ - searchTemplates(query: string, limit: number = 20): StoredTemplate[] { + searchTemplates(query: string, limit: number = 20, offset: number = 0): StoredTemplate[] { logger.debug(`Searching templates for: "${query}" (FTS5: ${this.hasFTS5Support})`); // If FTS5 is not supported, go straight to LIKE search if (!this.hasFTS5Support) { logger.debug('Using LIKE search (FTS5 not available)'); - return this.searchTemplatesLIKE(query, limit); + return this.searchTemplatesLIKE(query, limit, offset); } try { @@ -255,8 +255,8 @@ export class TemplateRepository { JOIN templates_fts ON t.id = templates_fts.rowid WHERE templates_fts MATCH ? ORDER BY rank, t.views DESC - LIMIT ? - `).all(ftsQuery, limit) as StoredTemplate[]; + LIMIT ? OFFSET ? + `).all(ftsQuery, limit, offset) as StoredTemplate[]; logger.debug(`FTS5 search returned ${results.length} results`); return results.map(t => this.decompressWorkflow(t)); @@ -267,14 +267,14 @@ export class TemplateRepository { query: query, ftsQuery: query.split(' ').map(term => `"${term}"`).join(' OR ') }); - return this.searchTemplatesLIKE(query, limit); + return this.searchTemplatesLIKE(query, limit, offset); } } /** * Fallback search using LIKE when FTS5 is not available */ - private searchTemplatesLIKE(query: string, limit: number = 20): StoredTemplate[] { + private searchTemplatesLIKE(query: string, limit: number = 20, offset: number = 0): StoredTemplate[] { const likeQuery = `%${query}%`; logger.debug(`Using LIKE search with pattern: ${likeQuery}`); @@ -282,8 +282,8 @@ export class TemplateRepository { SELECT * FROM templates WHERE name LIKE ? OR description LIKE ? ORDER BY views DESC, created_at DESC - LIMIT ? - `).all(likeQuery, likeQuery, limit) as StoredTemplate[]; + LIMIT ? OFFSET ? + `).all(likeQuery, likeQuery, limit, offset) as StoredTemplate[]; logger.debug(`LIKE search returned ${results.length} results`); return results.map(t => this.decompressWorkflow(t)); @@ -292,7 +292,7 @@ export class TemplateRepository { /** * Get templates for a specific task/use case */ - getTemplatesForTask(task: string): StoredTemplate[] { + getTemplatesForTask(task: string, limit: number = 10, offset: number = 0): StoredTemplate[] { // Map tasks to relevant node combinations const taskNodeMap: Record = { 'ai_automation': ['@n8n/n8n-nodes-langchain.openAi', '@n8n/n8n-nodes-langchain.agent', 'n8n-nodes-base.openAi'], @@ -312,18 +312,21 @@ export class TemplateRepository { return []; } - return this.getTemplatesByNodes(nodes, 10); + return this.getTemplatesByNodes(nodes, limit, offset); } /** * Get all templates with limit */ - getAllTemplates(limit: number = 10): StoredTemplate[] { + getAllTemplates(limit: number = 10, offset: number = 0, sortBy: 'views' | 'created_at' | 'name' = 'views'): StoredTemplate[] { + const orderClause = sortBy === 'name' ? 'name ASC' : + sortBy === 'created_at' ? 'created_at DESC' : + 'views DESC, created_at DESC'; const results = this.db.prepare(` SELECT * FROM templates - ORDER BY views DESC, created_at DESC - LIMIT ? - `).all(limit) as StoredTemplate[]; + ORDER BY ${orderClause} + LIMIT ? OFFSET ? + `).all(limit, offset) as StoredTemplate[]; return results.map(t => this.decompressWorkflow(t)); } @@ -335,6 +338,77 @@ export class TemplateRepository { return result.count; } + /** + * Get count for search results + */ + getSearchCount(query: string): number { + if (!this.hasFTS5Support) { + const likeQuery = `%${query}%`; + const result = this.db.prepare(` + SELECT COUNT(*) as count FROM templates + WHERE name LIKE ? OR description LIKE ? + `).get(likeQuery, likeQuery) as { count: number }; + return result.count; + } + + try { + const ftsQuery = query.split(' ').map(term => { + const escaped = term.replace(/"/g, '""'); + return `"${escaped}"`; + }).join(' OR '); + + const result = this.db.prepare(` + SELECT COUNT(*) as count FROM templates t + JOIN templates_fts ON t.id = templates_fts.rowid + WHERE templates_fts MATCH ? + `).get(ftsQuery) as { count: number }; + return result.count; + } catch { + const likeQuery = `%${query}%`; + const result = this.db.prepare(` + SELECT COUNT(*) as count FROM templates + WHERE name LIKE ? OR description LIKE ? + `).get(likeQuery, likeQuery) as { count: number }; + return result.count; + } + } + + /** + * Get count for node templates + */ + getNodeTemplatesCount(nodeTypes: string[]): number { + const conditions = nodeTypes.map(() => "nodes_used LIKE ?").join(" OR "); + const query = `SELECT COUNT(*) as count FROM templates WHERE ${conditions}`; + const params = nodeTypes.map(n => `%"${n}"%`); + const result = this.db.prepare(query).get(...params) as { count: number }; + return result.count; + } + + /** + * Get count for task templates + */ + getTaskTemplatesCount(task: string): number { + const taskNodeMap: Record = { + 'ai_automation': ['@n8n/n8n-nodes-langchain.openAi', '@n8n/n8n-nodes-langchain.agent', 'n8n-nodes-base.openAi'], + 'data_sync': ['n8n-nodes-base.googleSheets', 'n8n-nodes-base.postgres', 'n8n-nodes-base.mysql'], + 'webhook_processing': ['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest'], + 'email_automation': ['n8n-nodes-base.gmail', 'n8n-nodes-base.emailSend', 'n8n-nodes-base.emailReadImap'], + 'slack_integration': ['n8n-nodes-base.slack', 'n8n-nodes-base.slackTrigger'], + 'data_transformation': ['n8n-nodes-base.code', 'n8n-nodes-base.set', 'n8n-nodes-base.merge'], + 'file_processing': ['n8n-nodes-base.readBinaryFile', 'n8n-nodes-base.writeBinaryFile', 'n8n-nodes-base.googleDrive'], + 'scheduling': ['n8n-nodes-base.scheduleTrigger', 'n8n-nodes-base.cron'], + 'api_integration': ['n8n-nodes-base.httpRequest', 'n8n-nodes-base.graphql'], + 'database_operations': ['n8n-nodes-base.postgres', 'n8n-nodes-base.mysql', 'n8n-nodes-base.mongodb'] + }; + + const nodes = taskNodeMap[task]; + if (!nodes) { + return 0; + } + + return this.getNodeTemplatesCount(nodes); + } + /** * Get all existing template IDs for comparison * Used in update mode to skip already fetched templates diff --git a/src/templates/template-service.ts b/src/templates/template-service.ts index 3dfefd4..4c4e20e 100644 --- a/src/templates/template-service.ts +++ b/src/templates/template-service.ts @@ -21,6 +21,21 @@ export interface TemplateWithWorkflow extends TemplateInfo { workflow: any; } +export interface PaginatedResponse { + items: T[]; + total: number; + limit: number; + offset: number; + hasMore: boolean; +} + +export interface TemplateMinimal { + id: number; + name: string; + views: number; + nodeCount: number; +} + export class TemplateService { private repository: TemplateRepository; @@ -31,40 +46,115 @@ export class TemplateService { /** * List templates that use specific node types */ - async listNodeTemplates(nodeTypes: string[], limit: number = 10): Promise { - const templates = this.repository.getTemplatesByNodes(nodeTypes, limit); - return templates.map(this.formatTemplateInfo); + async listNodeTemplates(nodeTypes: string[], limit: number = 10, offset: number = 0): Promise> { + const templates = this.repository.getTemplatesByNodes(nodeTypes, limit, offset); + const total = this.repository.getNodeTemplatesCount(nodeTypes); + + return { + items: templates.map(this.formatTemplateInfo), + total, + limit, + offset, + hasMore: offset + limit < total + }; } /** - * Get a specific template with full workflow + * Get a specific template with different detail levels */ - async getTemplate(templateId: number): Promise { + async getTemplate(templateId: number, mode: 'nodes_only' | 'structure' | 'full' = 'full'): Promise { const template = this.repository.getTemplate(templateId); if (!template) { return null; } + const workflow = JSON.parse(template.workflow_json || '{}'); + + if (mode === 'nodes_only') { + return { + id: template.id, + name: template.name, + nodes: workflow.nodes?.map((n: any) => ({ + type: n.type, + name: n.name + })) || [] + }; + } + + if (mode === 'structure') { + return { + id: template.id, + name: template.name, + nodes: workflow.nodes?.map((n: any) => ({ + id: n.id, + type: n.type, + name: n.name, + position: n.position + })) || [], + connections: workflow.connections || {} + }; + } + + // Full mode return { ...this.formatTemplateInfo(template), - workflow: JSON.parse(template.workflow_json || '{}') + workflow }; } /** * Search templates by query */ - async searchTemplates(query: string, limit: number = 20): Promise { - const templates = this.repository.searchTemplates(query, limit); - return templates.map(this.formatTemplateInfo); + async searchTemplates(query: string, limit: number = 20, offset: number = 0): Promise> { + const templates = this.repository.searchTemplates(query, limit, offset); + const total = this.repository.getSearchCount(query); + + return { + items: templates.map(this.formatTemplateInfo), + total, + limit, + offset, + hasMore: offset + limit < total + }; } /** * Get templates for a specific task */ - async getTemplatesForTask(task: string): Promise { - const templates = this.repository.getTemplatesForTask(task); - return templates.map(this.formatTemplateInfo); + async getTemplatesForTask(task: string, limit: number = 10, offset: number = 0): Promise> { + const templates = this.repository.getTemplatesForTask(task, limit, offset); + const total = this.repository.getTaskTemplatesCount(task); + + return { + items: templates.map(this.formatTemplateInfo), + total, + limit, + offset, + hasMore: offset + limit < total + }; + } + + /** + * List all templates with minimal data + */ + async listTemplates(limit: number = 10, offset: number = 0, sortBy: 'views' | 'created_at' | 'name' = 'views'): Promise> { + const templates = this.repository.getAllTemplates(limit, offset, sortBy); + const total = this.repository.getTemplateCount(); + + const items = templates.map(t => ({ + id: t.id, + name: t.name, + views: t.views, + nodeCount: JSON.parse(t.nodes_used).length + })); + + return { + items, + total, + limit, + offset, + hasMore: offset + limit < total + }; } /**