diff --git a/CHANGELOG.md b/CHANGELOG.md index c40b2b1..1da1cc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project will be documented in this file. +## [1.3.14] - 2026-01-31 + +### Added + +- **Markdown to ProseMirror Converter:** New `src/utils/markdown-to-prosemirror.ts` + - Converts Markdown text to ProseMirror JSON format + - Supports: headings, paragraphs, lists, checkboxes, blockquotes, code blocks, hr + - Supports inline: bold, italic, links, inline code + - Documents now render with proper formatting in Outline + +- **Auto-update documentStructure:** `create_document` now updates collection's `documentStructure` + - New documents automatically appear in collection sidebar + - No manual database intervention needed + ## [1.3.13] - 2026-01-31 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 95c4f6a..4b58fba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co MCP server for direct PostgreSQL access to Outline Wiki database. Follows patterns established by `mcp-desk-crm-sql-v3`. -**Version:** 1.3.13 +**Version:** 1.3.14 **Total Tools:** 164 tools across 33 modules **Production:** hub.descomplicar.pt (via SSH tunnel) diff --git a/CONTINUE.md b/CONTINUE.md index fdb65fa..3ffe0c8 100644 --- a/CONTINUE.md +++ b/CONTINUE.md @@ -1,7 +1,7 @@ # MCP Outline PostgreSQL - Continuacao de Testes **Ultima Sessao:** 2026-01-31 (actualizado) -**Versao Actual:** 1.3.13 +**Versao Actual:** 1.3.14 **Progresso:** ~95/164 tools testadas (58%) - **CÓDIGO VALIDADO** --- diff --git a/package-lock.json b/package-lock.json index b4eeda8..017c5e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcp-outline-postgresql", - "version": "1.3.13", + "version": "1.3.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcp-outline-postgresql", - "version": "1.3.13", + "version": "1.3.14", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/package.json b/package.json index 6e78ee9..2fa6ff3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-outline-postgresql", - "version": "1.3.13", + "version": "1.3.14", "description": "MCP Server for Outline Wiki via PostgreSQL direct access", "main": "dist/index.js", "scripts": { diff --git a/src/index-http.ts b/src/index-http.ts index df7f16b..93063ba 100644 --- a/src/index-http.ts +++ b/src/index-http.ts @@ -68,7 +68,7 @@ async function main() { JSON.stringify({ status: 'ok', transport: 'streamable-http', - version: '1.3.13', + version: '1.3.14', sessions: sessions.size, stateful: STATEFUL, tools: allTools.length @@ -101,7 +101,7 @@ async function main() { // Create MCP server const server = createMcpServer(pgClient.getPool(), { name: 'mcp-outline-http', - version: '1.3.13' + version: '1.3.14' }); // Track session if stateful diff --git a/src/index.ts b/src/index.ts index 787710e..012f2eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,7 +39,7 @@ async function main() { // Create MCP server with shared configuration const server = createMcpServer(pgClient.getPool(), { name: 'mcp-outline-postgresql', - version: '1.3.13' + version: '1.3.14' }); // Connect stdio transport diff --git a/src/server/create-server.ts b/src/server/create-server.ts index 6099f49..75670c1 100644 --- a/src/server/create-server.ts +++ b/src/server/create-server.ts @@ -122,7 +122,7 @@ export function createMcpServer( ): Server { const server = new Server({ name: config.name || 'mcp-outline-postgresql', - version: config.version || '1.3.13' + version: config.version || '1.3.14' }); // Set capabilities (required for MCP v2.2+) diff --git a/src/tools/documents.ts b/src/tools/documents.ts index 88fa320..49f8124 100644 --- a/src/tools/documents.ts +++ b/src/tools/documents.ts @@ -6,6 +6,7 @@ import { Pool } from 'pg'; import { BaseTool, ToolResponse, DocumentArgs, GetDocumentArgs, CreateDocumentArgs, UpdateDocumentArgs, SearchDocumentsArgs, MoveDocumentArgs } from '../types/tools.js'; import { validatePagination, validateSortDirection, validateSortField, isValidUUID, sanitizeInput } from '../utils/security.js'; +import { markdownToProseMirror } from '../utils/markdown-to-prosemirror.js'; /** * 1. list_documents - Lista documentos publicados e drafts com filtros e paginação @@ -240,6 +241,9 @@ const createDocument: BaseTool = { await pgClient.query('BEGIN'); try { + // Convert Markdown to ProseMirror JSON + const proseMirrorContent = markdownToProseMirror(text); + const docQuery = ` INSERT INTO documents ( id, "urlId", title, text, "collectionId", "teamId", "parentDocumentId", "createdById", @@ -250,7 +254,7 @@ const createDocument: BaseTool = { gen_random_uuid(), substring(md5(random()::text) from 1 for 10), $1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), 1, false, false, false, ARRAY[$6]::uuid[], - 1, '{"type": "doc", "content": [{"type": "paragraph"}]}'::jsonb + 1, $10::jsonb ) RETURNING id, "urlId", title, "collectionId", "publishedAt", "createdAt" `; @@ -264,7 +268,8 @@ const createDocument: BaseTool = { userId, userId, args.template || false, - publishedAt + publishedAt, + JSON.stringify(proseMirrorContent) ]; const docResult = await pgClient.query(docQuery, docParams); @@ -288,6 +293,24 @@ const createDocument: BaseTool = { text ]); + // Update collection's documentStructure to include the new document + const urlSlug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + const updateStructureQuery = ` + UPDATE collections + SET "documentStructure" = COALESCE("documentStructure", '[]'::jsonb) || $1::jsonb, + "updatedAt" = NOW() + WHERE id = $2 + `; + await pgClient.query(updateStructureQuery, [ + JSON.stringify([{ + id: newDoc.id, + url: `/doc/${urlSlug}-${newDoc.urlId}`, + title: title, + children: [] + }]), + args.collection_id + ]); + await pgClient.query('COMMIT'); return { diff --git a/src/utils/markdown-to-prosemirror.ts b/src/utils/markdown-to-prosemirror.ts new file mode 100644 index 0000000..01f5cc4 --- /dev/null +++ b/src/utils/markdown-to-prosemirror.ts @@ -0,0 +1,310 @@ +/** + * Markdown to ProseMirror JSON converter + * Converts basic Markdown to Outline's ProseMirror schema + */ + +interface ProseMirrorNode { + type: string; + attrs?: Record; + content?: ProseMirrorNode[]; + text?: string; + marks?: Array<{ type: string; attrs?: Record }>; +} + +interface ProseMirrorDoc { + type: 'doc'; + content: ProseMirrorNode[]; +} + +/** + * Convert Markdown text to ProseMirror JSON + */ +export function markdownToProseMirror(markdown: string): ProseMirrorDoc { + const lines = markdown.split('\n'); + const content: ProseMirrorNode[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Empty line - skip + if (line.trim() === '') { + i++; + continue; + } + + // Horizontal rule + if (/^(-{3,}|_{3,}|\*{3,})$/.test(line.trim())) { + content.push({ type: 'hr' }); + i++; + continue; + } + + // Heading + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + content.push({ + type: 'heading', + attrs: { level: headingMatch[1].length }, + content: parseInlineContent(headingMatch[2]) + }); + i++; + continue; + } + + // Code block + if (line.startsWith('```')) { + const language = line.slice(3).trim() || null; + const codeLines: string[] = []; + i++; + while (i < lines.length && !lines[i].startsWith('```')) { + codeLines.push(lines[i]); + i++; + } + content.push({ + type: 'code_block', + attrs: { language }, + content: [{ type: 'text', text: codeLines.join('\n') }] + }); + i++; // skip closing ``` + continue; + } + + // Blockquote + if (line.startsWith('>')) { + const quoteLines: string[] = []; + while (i < lines.length && (lines[i].startsWith('>') || lines[i].trim() === '')) { + if (lines[i].startsWith('>')) { + quoteLines.push(lines[i].replace(/^>\s?/, '')); + } + i++; + if (lines[i]?.trim() === '' && !lines[i + 1]?.startsWith('>')) break; + } + content.push({ + type: 'blockquote', + content: [{ + type: 'paragraph', + content: parseInlineContent(quoteLines.join(' ')) + }] + }); + continue; + } + + // Bullet list + if (/^[-*+]\s/.test(line)) { + const items: ProseMirrorNode[] = []; + while (i < lines.length && /^[-*+]\s/.test(lines[i])) { + const itemText = lines[i].replace(/^[-*+]\s+/, ''); + items.push({ + type: 'list_item', + content: [{ type: 'paragraph', content: parseInlineContent(itemText) }] + }); + i++; + } + content.push({ type: 'bullet_list', content: items }); + continue; + } + + // Ordered list + if (/^\d+\.\s/.test(line)) { + const items: ProseMirrorNode[] = []; + while (i < lines.length && /^\d+\.\s/.test(lines[i])) { + const itemText = lines[i].replace(/^\d+\.\s+/, ''); + items.push({ + type: 'list_item', + content: [{ type: 'paragraph', content: parseInlineContent(itemText) }] + }); + i++; + } + content.push({ type: 'ordered_list', content: items }); + continue; + } + + // Checkbox list + if (/^[-*]\s+\[[ x]\]/.test(line)) { + const items: ProseMirrorNode[] = []; + while (i < lines.length && /^[-*]\s+\[[ x]\]/.test(lines[i])) { + const checked = /\[x\]/i.test(lines[i]); + const itemText = lines[i].replace(/^[-*]\s+\[[ x]\]\s*/, ''); + items.push({ + type: 'checkbox_item', + attrs: { checked }, + content: [{ type: 'paragraph', content: parseInlineContent(itemText) }] + }); + i++; + } + content.push({ type: 'checkbox_list', content: items }); + continue; + } + + // Default: paragraph + content.push({ + type: 'paragraph', + content: parseInlineContent(line) + }); + i++; + } + + // Ensure at least one empty paragraph if no content + if (content.length === 0) { + content.push({ type: 'paragraph' }); + } + + return { type: 'doc', content }; +} + +/** + * Parse inline content (bold, italic, links, code) + */ +function parseInlineContent(text: string): ProseMirrorNode[] { + if (!text || text.trim() === '') { + return []; + } + + const nodes: ProseMirrorNode[] = []; + let remaining = text; + + while (remaining.length > 0) { + // Inline code + const codeMatch = remaining.match(/^`([^`]+)`/); + if (codeMatch) { + nodes.push({ + type: 'text', + text: codeMatch[1], + marks: [{ type: 'code_inline' }] + }); + remaining = remaining.slice(codeMatch[0].length); + continue; + } + + // Bold + const boldMatch = remaining.match(/^\*\*([^*]+)\*\*/) || remaining.match(/^__([^_]+)__/); + if (boldMatch) { + nodes.push({ + type: 'text', + text: boldMatch[1], + marks: [{ type: 'bold' }] + }); + remaining = remaining.slice(boldMatch[0].length); + continue; + } + + // Italic + const italicMatch = remaining.match(/^\*([^*]+)\*/) || remaining.match(/^_([^_]+)_/); + if (italicMatch) { + nodes.push({ + type: 'text', + text: italicMatch[1], + marks: [{ type: 'italic' }] + }); + remaining = remaining.slice(italicMatch[0].length); + continue; + } + + // Link + const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/); + if (linkMatch) { + nodes.push({ + type: 'text', + text: linkMatch[1], + marks: [{ type: 'link', attrs: { href: linkMatch[2] } }] + }); + remaining = remaining.slice(linkMatch[0].length); + continue; + } + + // Plain text - consume until next special char or end + const plainMatch = remaining.match(/^[^`*_\[]+/); + if (plainMatch) { + nodes.push({ type: 'text', text: plainMatch[0] }); + remaining = remaining.slice(plainMatch[0].length); + continue; + } + + // Fallback: consume single character + nodes.push({ type: 'text', text: remaining[0] }); + remaining = remaining.slice(1); + } + + return nodes; +} + +/** + * Convert ProseMirror JSON back to Markdown + */ +export function proseMirrorToMarkdown(doc: ProseMirrorDoc): string { + return doc.content.map(node => nodeToMarkdown(node)).join('\n\n'); +} + +function nodeToMarkdown(node: ProseMirrorNode, indent = ''): string { + switch (node.type) { + case 'heading': + const level = (node.attrs?.level as number) || 1; + return '#'.repeat(level) + ' ' + contentToMarkdown(node.content); + + case 'paragraph': + return contentToMarkdown(node.content); + + case 'bullet_list': + return (node.content || []) + .map(item => '- ' + nodeToMarkdown(item.content?.[0] || { type: 'paragraph' })) + .join('\n'); + + case 'ordered_list': + return (node.content || []) + .map((item, i) => `${i + 1}. ` + nodeToMarkdown(item.content?.[0] || { type: 'paragraph' })) + .join('\n'); + + case 'checkbox_list': + return (node.content || []) + .map(item => { + const checked = item.attrs?.checked ? 'x' : ' '; + return `- [${checked}] ` + nodeToMarkdown(item.content?.[0] || { type: 'paragraph' }); + }) + .join('\n'); + + case 'blockquote': + return (node.content || []) + .map(child => '> ' + nodeToMarkdown(child)) + .join('\n'); + + case 'code_block': + const lang = node.attrs?.language || ''; + return '```' + lang + '\n' + contentToMarkdown(node.content) + '\n```'; + + case 'hr': + return '---'; + + default: + return contentToMarkdown(node.content); + } +} + +function contentToMarkdown(content?: ProseMirrorNode[]): string { + if (!content) return ''; + return content.map(node => { + if (node.type === 'text') { + let text = node.text || ''; + if (node.marks) { + for (const mark of node.marks) { + switch (mark.type) { + case 'bold': + text = `**${text}**`; + break; + case 'italic': + text = `*${text}*`; + break; + case 'code_inline': + text = `\`${text}\``; + break; + case 'link': + text = `[${text}](${mark.attrs?.href})`; + break; + } + } + } + return text; + } + return nodeToMarkdown(node); + }).join(''); +}