Production Outline DB uses 'icon' column instead of 'emoji' for documents and revisions. Fixed all affected queries: - documents.ts: SELECT queries - advanced-search.ts: Search queries - analytics.ts: Analytics + GROUP BY - export-import.ts: Export/import metadata - templates.ts: Template queries + INSERT - collections.ts: Collection document listing - revisions.ts: Revision comparison reactions.emoji kept unchanged (correct schema) Tested: 448 documents successfully queried from hub.descomplicar.pt Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
224 lines
7.8 KiB
TypeScript
224 lines
7.8 KiB
TypeScript
/**
|
|
* MCP Outline PostgreSQL - Templates Tools
|
|
* Templates are documents with template=true
|
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
|
*/
|
|
|
|
import { Pool } from 'pg';
|
|
import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js';
|
|
import { validatePagination, isValidUUID, sanitizeInput } from '../utils/security.js';
|
|
|
|
interface TemplateListArgs extends PaginationArgs {
|
|
collection_id?: string;
|
|
}
|
|
|
|
/**
|
|
* templates.list - List templates
|
|
*/
|
|
const listTemplates: BaseTool<TemplateListArgs> = {
|
|
name: 'outline_list_templates',
|
|
description: 'List document templates. Templates are reusable document structures.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
collection_id: { type: 'string', description: 'Filter by collection ID (UUID)' },
|
|
limit: { type: 'number', description: 'Max results (default: 25)' },
|
|
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
|
},
|
|
},
|
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
|
const conditions: string[] = ['d.template = true', 'd."deletedAt" IS NULL'];
|
|
const params: any[] = [];
|
|
let idx = 1;
|
|
|
|
if (args.collection_id) {
|
|
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
|
conditions.push(`d."collectionId" = $${idx++}`);
|
|
params.push(args.collection_id);
|
|
}
|
|
|
|
const result = await pgClient.query(`
|
|
SELECT
|
|
d.id, d.title, d.icon, d."collectionId", d."createdById",
|
|
d."createdAt", d."updatedAt",
|
|
c.name as "collectionName",
|
|
u.name as "createdByName",
|
|
(SELECT COUNT(*) FROM documents WHERE "templateId" = d.id) as "usageCount"
|
|
FROM documents d
|
|
LEFT JOIN collections c ON d."collectionId" = c.id
|
|
LEFT JOIN users u ON d."createdById" = u.id
|
|
WHERE ${conditions.join(' AND ')}
|
|
ORDER BY d."updatedAt" DESC
|
|
LIMIT $${idx++} OFFSET $${idx}
|
|
`, [...params, limit, offset]);
|
|
|
|
return {
|
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }],
|
|
};
|
|
},
|
|
};
|
|
|
|
/**
|
|
* templates.info - Get template details
|
|
*/
|
|
const getTemplate: BaseTool<{ id: string }> = {
|
|
name: 'outline_get_template',
|
|
description: 'Get detailed information about a template including its content.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string', description: 'Template ID (UUID)' },
|
|
},
|
|
required: ['id'],
|
|
},
|
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
|
if (!isValidUUID(args.id)) throw new Error('Invalid template ID');
|
|
|
|
const result = await pgClient.query(`
|
|
SELECT
|
|
d.*, c.name as "collectionName", u.name as "createdByName",
|
|
(SELECT COUNT(*) FROM documents WHERE "templateId" = d.id) as "usageCount"
|
|
FROM documents d
|
|
LEFT JOIN collections c ON d."collectionId" = c.id
|
|
LEFT JOIN users u ON d."createdById" = u.id
|
|
WHERE d.id = $1 AND d.template = true AND d."deletedAt" IS NULL
|
|
`, [args.id]);
|
|
|
|
if (result.rows.length === 0) throw new Error('Template not found');
|
|
|
|
return {
|
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0] }, null, 2) }],
|
|
};
|
|
},
|
|
};
|
|
|
|
/**
|
|
* templates.create - Create document from template
|
|
*/
|
|
const createFromTemplate: BaseTool<{ template_id: string; title: string; collection_id?: string; parent_document_id?: string }> = {
|
|
name: 'outline_create_from_template',
|
|
description: 'Create a new document from a template.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
template_id: { type: 'string', description: 'Template ID (UUID)' },
|
|
title: { type: 'string', description: 'Title for the new document' },
|
|
collection_id: { type: 'string', description: 'Collection ID (UUID, optional - uses template collection)' },
|
|
parent_document_id: { type: 'string', description: 'Parent document ID (UUID, optional)' },
|
|
},
|
|
required: ['template_id', 'title'],
|
|
},
|
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
|
if (!isValidUUID(args.template_id)) throw new Error('Invalid template_id');
|
|
if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
|
if (args.parent_document_id && !isValidUUID(args.parent_document_id)) throw new Error('Invalid parent_document_id');
|
|
|
|
// Get template
|
|
const template = await pgClient.query(
|
|
`SELECT * FROM documents WHERE id = $1 AND template = true AND "deletedAt" IS NULL`,
|
|
[args.template_id]
|
|
);
|
|
|
|
if (template.rows.length === 0) throw new Error('Template not found');
|
|
|
|
const t = template.rows[0];
|
|
|
|
// Get user
|
|
const user = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`);
|
|
const userId = user.rows.length > 0 ? user.rows[0].id : t.createdById;
|
|
|
|
// Create document from template
|
|
const result = await pgClient.query(`
|
|
INSERT INTO documents (
|
|
id, title, text, icon, "collectionId", "teamId", "parentDocumentId",
|
|
"templateId", "createdById", "lastModifiedById", template,
|
|
"createdAt", "updatedAt"
|
|
)
|
|
VALUES (
|
|
gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $8, false, NOW(), NOW()
|
|
)
|
|
RETURNING *
|
|
`, [
|
|
sanitizeInput(args.title),
|
|
t.text,
|
|
t.icon,
|
|
args.collection_id || t.collectionId,
|
|
t.teamId,
|
|
args.parent_document_id || null,
|
|
args.template_id,
|
|
userId,
|
|
]);
|
|
|
|
return {
|
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Document created from template' }, null, 2) }],
|
|
};
|
|
},
|
|
};
|
|
|
|
/**
|
|
* templates.convert - Convert document to template
|
|
*/
|
|
const convertToTemplate: BaseTool<{ document_id: string }> = {
|
|
name: 'outline_convert_to_template',
|
|
description: 'Convert an existing document to a template.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
document_id: { type: 'string', description: 'Document ID to convert (UUID)' },
|
|
},
|
|
required: ['document_id'],
|
|
},
|
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
|
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
|
|
|
|
const result = await pgClient.query(`
|
|
UPDATE documents
|
|
SET template = true, "updatedAt" = NOW()
|
|
WHERE id = $1 AND "deletedAt" IS NULL AND template = false
|
|
RETURNING id, title, template
|
|
`, [args.document_id]);
|
|
|
|
if (result.rows.length === 0) throw new Error('Document not found or already a template');
|
|
|
|
return {
|
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Converted to template' }, null, 2) }],
|
|
};
|
|
},
|
|
};
|
|
|
|
/**
|
|
* templates.unconvert - Convert template back to document
|
|
*/
|
|
const convertFromTemplate: BaseTool<{ template_id: string }> = {
|
|
name: 'outline_convert_from_template',
|
|
description: 'Convert a template back to a regular document.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
template_id: { type: 'string', description: 'Template ID to convert (UUID)' },
|
|
},
|
|
required: ['template_id'],
|
|
},
|
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
|
if (!isValidUUID(args.template_id)) throw new Error('Invalid template_id');
|
|
|
|
const result = await pgClient.query(`
|
|
UPDATE documents
|
|
SET template = false, "updatedAt" = NOW()
|
|
WHERE id = $1 AND "deletedAt" IS NULL AND template = true
|
|
RETURNING id, title, template
|
|
`, [args.template_id]);
|
|
|
|
if (result.rows.length === 0) throw new Error('Template not found or already a document');
|
|
|
|
return {
|
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Converted to document' }, null, 2) }],
|
|
};
|
|
},
|
|
};
|
|
|
|
export const templatesTools: BaseTool<any>[] = [
|
|
listTemplates, getTemplate, createFromTemplate, convertToTemplate, convertFromTemplate
|
|
];
|