Compare commits
12 Commits
d5b92399b9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fcef454ee | |||
| 1e462b5c49 | |||
| d1561195bf | |||
| f640465f86 | |||
| 12d3b26454 | |||
| 114895ff56 | |||
| b0ec9558f2 | |||
| 9598aba0bf | |||
| f1df797ac4 | |||
| 12710c2b2f | |||
| 1cdeafebb6 | |||
| 1c8f6cbab9 |
108
CHANGELOG.md
108
CHANGELOG.md
@@ -2,6 +2,114 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [1.3.17] - 2026-02-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Document editorVersion:** Added missing `editorVersion` field set to `15.0.0`
|
||||||
|
- Documents without this field return "Not found" in Outline
|
||||||
|
- Critical fix for document visibility
|
||||||
|
|
||||||
|
## [1.3.16] - 2026-02-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Table Support in Markdown Converter:** Tables now render properly in Outline
|
||||||
|
- Parses Markdown table syntax (`| Col1 | Col2 |`)
|
||||||
|
- Converts to ProseMirror table structure with `table`, `tr`, `th`, `td` nodes
|
||||||
|
- Supports header rows (first row becomes `th` elements)
|
||||||
|
- Handles variable column counts with proper padding
|
||||||
|
- Bidirectional: ProseMirror tables also convert back to Markdown
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Checkbox List:** Already supported but confirmed working with `checkbox_list` and `checkbox_item` node types
|
||||||
|
|
||||||
|
## [1.3.15] - 2026-01-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **ProseMirror Mark Types:** Fixed mark type names to match Outline schema
|
||||||
|
- `bold` → `strong`
|
||||||
|
- `italic` → `em`
|
||||||
|
- Error was: "RangeError: There is no mark type bold in this schema"
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
- **Document Listing (Final Fix):** Documents now appear in collection sidebar
|
||||||
|
- Added `revisionCount = 1` (was 0, Outline filters these out)
|
||||||
|
- Added `content` field with minimal ProseMirror JSON structure
|
||||||
|
- Both fields required for documents to appear in listing
|
||||||
|
|
||||||
|
## [1.3.12] - 2026-01-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Document Listing:** Documents now appear in collection sidebar
|
||||||
|
- `collaboratorIds` must contain the creator's userId, not empty array
|
||||||
|
- Documents with empty `collaboratorIds` don't appear in listing
|
||||||
|
- Now uses `ARRAY[$userId]::uuid[]` to include creator
|
||||||
|
|
||||||
|
## [1.3.11] - 2026-01-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Document collaboratorIds:** Added missing `collaboratorIds` field (empty array)
|
||||||
|
- Error: `TypeError: b.collaboratorIds is not iterable`
|
||||||
|
- Outline expects this field to be an array, not NULL
|
||||||
|
|
||||||
|
## [1.3.10] - 2026-01-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Document urlId Format:** Fixed urlId generation to match Outline format
|
||||||
|
- Was: 21-char hex string (e.g., `86734d15885647618cb16`)
|
||||||
|
- Now: 10-char alphanumeric (e.g., `b0a14475ff`)
|
||||||
|
- Documents with wrong urlId format returned 404 "Not found"
|
||||||
|
|
||||||
|
## [1.3.9] - 2026-01-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Document Visibility (Critical):** `create_document` now creates initial revision
|
||||||
|
- Was: Documents created via MCP didn't appear in Outline interface
|
||||||
|
- Cause: Outline requires entry in `revisions` table to display documents
|
||||||
|
- Now: Uses transaction to insert into both `documents` and `revisions` tables
|
||||||
|
- Documents created via MCP now visible immediately in Outline
|
||||||
|
|
||||||
|
## [1.3.8] - 2026-01-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Collection Sort Field:** `create_collection` now sets default `sort` value
|
||||||
|
- Was: `sort` column left NULL, causing frontend error "Cannot read properties of null (reading 'field')"
|
||||||
|
- Now: Sets `{"field": "index", "direction": "asc"}` as default
|
||||||
|
- Outline frontend requires this field to render collections
|
||||||
|
|
||||||
|
## [1.3.7] - 2026-01-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Tool Name Length:** Shortened `outline_bulk_remove_users_from_collection` to `outline_bulk_remove_collection_users`
|
||||||
|
- MCP tool names with prefix `mcp__outline-postgresql__` were exceeding 64 character limit
|
||||||
|
- Claude API returns error 400 for tool names > 64 chars
|
||||||
|
|
||||||
## [1.3.6] - 2026-01-31
|
## [1.3.6] - 2026-01-31
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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`.
|
MCP server for direct PostgreSQL access to Outline Wiki database. Follows patterns established by `mcp-desk-crm-sql-v3`.
|
||||||
|
|
||||||
**Version:** 1.3.6
|
**Version:** 1.3.15
|
||||||
**Total Tools:** 164 tools across 33 modules
|
**Total Tools:** 164 tools across 33 modules
|
||||||
**Production:** hub.descomplicar.pt (via SSH tunnel)
|
**Production:** hub.descomplicar.pt (via SSH tunnel)
|
||||||
|
|
||||||
|
|||||||
233
CONTINUE.md
233
CONTINUE.md
@@ -1,205 +1,130 @@
|
|||||||
# MCP Outline PostgreSQL - Continuacao de Testes
|
# MCP Outline PostgreSQL - Continuação
|
||||||
|
|
||||||
**Ultima Sessao:** 2026-01-31 (actualizado)
|
**Última Sessão:** 2026-02-01
|
||||||
**Versao Actual:** 1.3.6
|
**Versão Actual:** 1.3.17
|
||||||
**Progresso:** ~95/164 tools testadas (58%) - **CÓDIGO VALIDADO**
|
**Estado:** ⚠️ Bug "Not found" por resolver
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Estado Actual
|
## Bug Pendente: Documentos "Not found"
|
||||||
|
|
||||||
### Validacao Completa (31 Jan - tarde/noite)
|
### Sintoma
|
||||||
|
Documentos criados via MCP aparecem na listagem mas ao abrir mostram "Not found".
|
||||||
|
|
||||||
1. **Código Fonte Verificado** ✅
|
### Investigação Feita (01 Fev)
|
||||||
- Todos os 6 bugs corrigidos confirmados no código
|
|
||||||
- INSERT statements com colunas NOT NULL correctas
|
|
||||||
- Lógica de criação de IDs correcta
|
|
||||||
|
|
||||||
2. **Testes Unitários** ✅
|
Documento de teste: `https://hub.descomplicar.pt/doc/teste-mermaid-diagrams-c051be722b`
|
||||||
- 209/209 testes passam
|
|
||||||
- Cobertura: security, validation, pagination, query-builder, tools-structure
|
|
||||||
|
|
||||||
3. **Servidor HTTP** ✅
|
**Campos verificados na BD - TODOS CORRECTOS:**
|
||||||
- Inicia correctamente
|
|
||||||
- 164 tools registadas
|
|
||||||
- Todos os módulos carregados
|
|
||||||
|
|
||||||
4. **Builds** ✅
|
| Campo | Valor | Status |
|
||||||
- TypeScript compila sem erros
|
|-------|-------|--------|
|
||||||
- dist/ actualizado
|
| `id` | `a2321367-0bf8-4225-bdf9-c99769912442` | ✅ UUID válido |
|
||||||
|
| `urlId` | `c051be722b` | ✅ 10 chars |
|
||||||
|
| `revisionCount` | `1` | ✅ |
|
||||||
|
| `collaboratorIds` | `[userId]` | ✅ Array preenchido |
|
||||||
|
| `publishedAt` | `2026-02-01T13:03:58.198Z` | ✅ Definido |
|
||||||
|
| `teamId` | `c3b7d636-5106-463c-9000-5b154431f18f` | ✅ |
|
||||||
|
| `content` | ProseMirror JSON válido | ✅ 15 nodes |
|
||||||
|
| `editorVersion` | `15.0.0` | ✅ Adicionado |
|
||||||
|
| `revisions` | 1 entrada | ✅ |
|
||||||
|
| `documentStructure` | Incluído na collection | ✅ |
|
||||||
|
|
||||||
### Bugs Corrigidos (CONFIRMADOS)
|
**Comparação com documento funcional:**
|
||||||
|
- Único campo diferente era `editorVersion` (null vs 15.0.0)
|
||||||
|
- Corrigido para `15.0.0` - MAS continua a falhar
|
||||||
|
|
||||||
| Bug | Ficheiro | Linha | Fix Verificado |
|
### Próximos Passos de Debug
|
||||||
|-----|----------|-------|----------------|
|
|
||||||
| 1 | `src/tools/auth.ts` | - | Removida `updatedAt` inexistente ✅ |
|
|
||||||
| 2 | `src/tools/subscriptions.ts` | - | LIMIT 25 adicionado ✅ |
|
|
||||||
| 3 | `src/tools/collections.ts` | - | `documentStructure` removido da list ✅ |
|
|
||||||
| 4 | `src/tools/documents.ts` | 239-251 | `id`, `urlId`, `teamId` + NOT NULLs ✅ |
|
|
||||||
| 5 | `src/tools/collections.ts` | 272-281 | `id`, `urlId`, `maintainerApprovalRequired` ✅ |
|
|
||||||
| 6 | `src/tools/shares.ts` | 276-292 | `id`, `urlId`, `allowIndexing`, `showLastUpdated` ✅ |
|
|
||||||
|
|
||||||
---
|
1. **Verificar logs do Outline** - Pode haver erro específico no servidor
|
||||||
|
2. **Comparar TODOS os campos** - Pode haver campo não verificado
|
||||||
|
3. **Testar criar documento via UI** - Comparar inserção completa
|
||||||
|
4. **Verificar Redis/cache** - Outline pode usar cache
|
||||||
|
|
||||||
## Proximos Passos
|
### Código Adicionado (v1.3.16-1.3.17)
|
||||||
|
|
||||||
### Pronto para Producao
|
```typescript
|
||||||
|
// src/tools/documents.ts - Campos adicionados ao INSERT:
|
||||||
|
- editorVersion: '15.0.0'
|
||||||
|
- content: ProseMirror JSON (via markdownToProseMirror)
|
||||||
|
- collaboratorIds: ARRAY[userId]
|
||||||
|
- revisionCount: 1
|
||||||
|
|
||||||
O código está validado e pronto. Para testar via MCP em Claude Code:
|
// src/utils/markdown-to-prosemirror.ts - Novo conversor:
|
||||||
|
- Headings, paragraphs, lists
|
||||||
1. **Reiniciar sessão Claude Code** (para recarregar MCPs)
|
- Checkboxes (checkbox_list, checkbox_item)
|
||||||
2. **Verificar túnel:** `./start-tunnel.sh status`
|
- Tables (table, tr, th, td) - v1.3.16
|
||||||
3. **Carregar tool:** `ToolSearch: select:mcp__outline-postgresql__create_document`
|
- Code blocks, blockquotes, hr
|
||||||
4. **Testar operação de escrita**
|
- Inline: strong, em, code_inline, link
|
||||||
|
|
||||||
### Round 4: Edge Cases (Quando MCP disponível)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// UUIDs inválidos
|
|
||||||
get_document({ id: "invalid-uuid" })
|
|
||||||
get_document({ id: "" })
|
|
||||||
|
|
||||||
// IDs inexistentes
|
|
||||||
get_document({ id: "00000000-0000-0000-0000-000000000000" })
|
|
||||||
|
|
||||||
// Limites de paginação
|
|
||||||
list_documents({ limit: 0 })
|
|
||||||
list_documents({ limit: 1000 })
|
|
||||||
list_documents({ offset: -1 })
|
|
||||||
|
|
||||||
// Queries vazias
|
|
||||||
search_documents({ query: "" })
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tools Testadas (~95)
|
## Versões Recentes
|
||||||
|
|
||||||
| Categoria | Tools | Status |
|
| Versão | Data | Alteração |
|
||||||
|-----------|-------|--------|
|
|--------|------|-----------|
|
||||||
| **Read Operations** | | |
|
| 1.3.17 | 01-02 | Fix editorVersion (não resolveu) |
|
||||||
| Documents | list, get, search | ✅ |
|
| 1.3.16 | 01-02 | Suporte tabelas no conversor |
|
||||||
| Collections | list, get | ✅ (fixed) |
|
| 1.3.15 | 31-01 | Fix mark types (strong/em) |
|
||||||
| Users | list, get | ✅ |
|
| 1.3.14 | 31-01 | Conversor Markdown→ProseMirror |
|
||||||
| Groups | list, get | ✅ |
|
| 1.3.13 | 31-01 | Fix revisionCount + content |
|
||||||
| Comments | list, get | ✅ |
|
|
||||||
| Shares | list, get | ✅ |
|
|
||||||
| Revisions | list, info | ✅ |
|
|
||||||
| Events | list, stats | ✅ |
|
|
||||||
| Attachments | list, stats | ✅ |
|
|
||||||
| File Operations | list | ✅ |
|
|
||||||
| OAuth | clients_list, auth_list | ✅ |
|
|
||||||
| Auth | info, config | ✅ (fixed) |
|
|
||||||
| Stars | list | ✅ |
|
|
||||||
| Pins | list | ✅ |
|
|
||||||
| Views | list | ✅ |
|
|
||||||
| Reactions | list | ✅ |
|
|
||||||
| API Keys | list | ✅ |
|
|
||||||
| Webhooks | list | ✅ |
|
|
||||||
| Backlinks | list | ✅ |
|
|
||||||
| Search Queries | list, stats | ✅ |
|
|
||||||
| Teams | get, stats, domains | ✅ |
|
|
||||||
| Integrations | list | ✅ |
|
|
||||||
| Notifications | list, settings | ✅ |
|
|
||||||
| Subscriptions | list, settings | ✅ (fixed) |
|
|
||||||
| Templates | list | ✅ |
|
|
||||||
| Imports | list | ✅ |
|
|
||||||
| Emojis | list | ✅ |
|
|
||||||
| User Permissions | list | ✅ |
|
|
||||||
| Analytics | Todos 6 tools | ✅ |
|
|
||||||
| Advanced Search | Todos 6 tools | ✅ |
|
|
||||||
| **Write Operations (código validado)** | | |
|
|
||||||
| Documents | create, update, archive, restore, delete | ✅ código |
|
|
||||||
| Collections | create, delete | ✅ código |
|
|
||||||
| Groups | create, delete | ✅ código |
|
|
||||||
| Comments | create, delete | ✅ código |
|
|
||||||
| Shares | create, revoke | ✅ código |
|
|
||||||
| Stars | create, delete | ✅ código |
|
|
||||||
| Pins | create, delete | ✅ código |
|
|
||||||
| API Keys | create, delete | ✅ código |
|
|
||||||
| Webhooks | create, delete | ✅ código |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## IDs Úteis para Testes
|
## IDs Úteis
|
||||||
|
|
||||||
### Team
|
| Recurso | ID |
|
||||||
- **Team ID:** `c3b7d636-5106-463c-9000-5b154431f18f`
|
|---------|-----|
|
||||||
- **Team Name:** Descomplicar
|
| Team | `c3b7d636-5106-463c-9000-5b154431f18f` |
|
||||||
|
| User | `e46960fd-ac44-4d32-a3c1-bcc10ac75afe` |
|
||||||
### User
|
| Collection Teste | `27927cb9-8e09-4193-98b0-3e23f08afa38` |
|
||||||
- **User ID:** `e46960fd-ac44-4d32-a3c1-bcc10ac75afe`
|
| Doc problemático | `a2321367-0bf8-4225-bdf9-c99769912442` |
|
||||||
- **Name:** Emanuel Almeida
|
|
||||||
- **Email:** emanuel@descomplicar.pt
|
|
||||||
|
|
||||||
### Collections
|
|
||||||
| ID | Nome | Docs |
|
|
||||||
|----|------|------|
|
|
||||||
| `951a06ff-d500-4714-9aa0-6b9f9c34318a` | Planeamento-v2 | 282 |
|
|
||||||
| `e27bb4ad-5113-43f8-bd8b-56b3d8a89028` | Planeamento | 180 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Comandos Úteis
|
## Comandos
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Rebuild após alterações
|
# Build
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# Correr testes
|
# Testes
|
||||||
npm test
|
npm test
|
||||||
|
|
||||||
# Verificar tunnel SSH
|
# Túnel
|
||||||
./start-tunnel.sh status
|
./start-tunnel.sh status
|
||||||
|
|
||||||
# Iniciar tunnel se necessário
|
# Query BD via Node
|
||||||
./start-tunnel.sh start
|
DATABASE_URL="postgres://postgres:9817e213507113fe607d@localhost:5433/descomplicar" node -e "
|
||||||
|
const { Pool } = require('pg');
|
||||||
# Testar servidor HTTP
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
DATABASE_URL="postgres://postgres:***@localhost:5433/descomplicar" node dist/index-http.js
|
pool.query('SELECT * FROM documents WHERE id = \\'ID\\'').then(console.log);
|
||||||
# Depois: curl http://localhost:3200/health
|
"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ficheiros Relevantes
|
|
||||||
|
|
||||||
| Ficheiro | Descrição |
|
|
||||||
|----------|-----------|
|
|
||||||
| `TESTING-GUIDE.md` | Guia completo com status de cada tool |
|
|
||||||
| `CHANGELOG.md` | Histórico de alterações |
|
|
||||||
| `CLAUDE.md` | Instruções para Claude Code |
|
|
||||||
| `src/tools/*.ts` | Implementação das ferramentas |
|
|
||||||
| `src/utils/security.ts` | Validações e segurança |
|
|
||||||
| `dist/` | Código compilado (usado pelo MCP) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prompt Para Continuar
|
## Prompt Para Continuar
|
||||||
|
|
||||||
```
|
```
|
||||||
Continuo os testes do MCP Outline PostgreSQL.
|
Continuo debug do MCP Outline PostgreSQL.
|
||||||
|
|
||||||
Path: /home/ealmeida/mcp-servers/mcp-outline-postgresql
|
Path: /home/ealmeida/mcp-servers/mcp-outline-postgresql
|
||||||
Versão: 1.3.6
|
Versão: 1.3.17
|
||||||
Estado: Código 100% validado, pronto para testes MCP
|
|
||||||
|
|
||||||
ESTADO ACTUAL:
|
BUG PENDENTE: Documentos criados via MCP mostram "Not found" ao abrir.
|
||||||
- 6 bugs corrigidos e verificados no código fonte
|
- Documento teste: a2321367-0bf8-4225-bdf9-c99769912442
|
||||||
- 209/209 testes unitários passam
|
- URL: hub.descomplicar.pt/doc/teste-mermaid-diagrams-c051be722b
|
||||||
- Servidor HTTP funcional (164 tools)
|
- Todos os campos verificados parecem correctos
|
||||||
|
- editorVersion já foi corrigido para 15.0.0
|
||||||
|
|
||||||
TAREFA: Testar operações de escrita via MCP
|
PRÓXIMO PASSO: Verificar logs do servidor Outline ou comparar
|
||||||
1. Verificar túnel: ./start-tunnel.sh status
|
inserção completa com documento criado via UI.
|
||||||
2. Carregar tool: ToolSearch select:mcp__outline-postgresql__create_document
|
|
||||||
3. Criar documento de teste na collection Planeamento-v2
|
|
||||||
4. Testar update, archive, restore, delete
|
|
||||||
|
|
||||||
Se MCP não disponível, as tools precisam ser carregadas numa nova sessão.
|
Ver CONTINUE.md para detalhes da investigação.
|
||||||
|
|
||||||
Ver CONTINUE.md para detalhes completos.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Actualizado: 2026-01-31 ~18:30 | Próxima sessão: Testar MCP tools (se disponíveis)*
|
*Actualizado: 2026-02-01 ~14:30*
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ Test error handling, invalid inputs, empty results.
|
|||||||
| `outline_bulk_move_documents` | 🔄 | |
|
| `outline_bulk_move_documents` | 🔄 | |
|
||||||
| `outline_bulk_restore_documents` | 🔄 | |
|
| `outline_bulk_restore_documents` | 🔄 | |
|
||||||
| `outline_bulk_add_users_to_collection` | 🔄 | |
|
| `outline_bulk_add_users_to_collection` | 🔄 | |
|
||||||
| `outline_bulk_remove_users_from_collection` | 🔄 | |
|
| `outline_bulk_remove_collection_users` | 🔄 | |
|
||||||
|
|
||||||
### 30. Advanced Search (6 tools)
|
### 30. Advanced Search (6 tools)
|
||||||
|
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mcp-outline-postgresql",
|
"name": "mcp-outline-postgresql",
|
||||||
"version": "1.3.6",
|
"version": "1.3.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mcp-outline-postgresql",
|
"name": "mcp-outline-postgresql",
|
||||||
"version": "1.3.6",
|
"version": "1.3.15",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mcp-outline-postgresql",
|
"name": "mcp-outline-postgresql",
|
||||||
"version": "1.3.6",
|
"version": "1.3.17",
|
||||||
"description": "MCP Server for Outline Wiki via PostgreSQL direct access",
|
"description": "MCP Server for Outline Wiki via PostgreSQL direct access",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ async function main() {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
transport: 'streamable-http',
|
transport: 'streamable-http',
|
||||||
version: '1.3.1',
|
version: '1.3.17',
|
||||||
sessions: sessions.size,
|
sessions: sessions.size,
|
||||||
stateful: STATEFUL,
|
stateful: STATEFUL,
|
||||||
tools: allTools.length
|
tools: allTools.length
|
||||||
@@ -101,7 +101,7 @@ async function main() {
|
|||||||
// Create MCP server
|
// Create MCP server
|
||||||
const server = createMcpServer(pgClient.getPool(), {
|
const server = createMcpServer(pgClient.getPool(), {
|
||||||
name: 'mcp-outline-http',
|
name: 'mcp-outline-http',
|
||||||
version: '1.3.1'
|
version: '1.3.17'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track session if stateful
|
// Track session if stateful
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async function main() {
|
|||||||
// Create MCP server with shared configuration
|
// Create MCP server with shared configuration
|
||||||
const server = createMcpServer(pgClient.getPool(), {
|
const server = createMcpServer(pgClient.getPool(), {
|
||||||
name: 'mcp-outline-postgresql',
|
name: 'mcp-outline-postgresql',
|
||||||
version: '1.3.1'
|
version: '1.3.17'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Connect stdio transport
|
// Connect stdio transport
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function createMcpServer(
|
|||||||
): Server {
|
): Server {
|
||||||
const server = new Server({
|
const server = new Server({
|
||||||
name: config.name || 'mcp-outline-postgresql',
|
name: config.name || 'mcp-outline-postgresql',
|
||||||
version: config.version || '1.3.1'
|
version: config.version || '1.3.17'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set capabilities (required for MCP v2.2+)
|
// Set capabilities (required for MCP v2.2+)
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ const bulkAddUsersToCollection: BaseTool<{ user_ids: string[]; collection_id: st
|
|||||||
* bulk.remove_users_from_collection - Remove multiple users from collection
|
* bulk.remove_users_from_collection - Remove multiple users from collection
|
||||||
*/
|
*/
|
||||||
const bulkRemoveUsersFromCollection: BaseTool<{ user_ids: string[]; collection_id: string }> = {
|
const bulkRemoveUsersFromCollection: BaseTool<{ user_ids: string[]; collection_id: string }> = {
|
||||||
name: 'outline_bulk_remove_users_from_collection',
|
name: 'outline_bulk_remove_collection_users',
|
||||||
description: 'Remove multiple users from a collection.',
|
description: 'Remove multiple users from a collection.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
|||||||
@@ -272,12 +272,12 @@ export const collectionsTools: BaseTool<any>[] = [
|
|||||||
const query = `
|
const query = `
|
||||||
INSERT INTO collections (
|
INSERT INTO collections (
|
||||||
id, name, "urlId", "teamId", "createdById", description, icon, color,
|
id, name, "urlId", "teamId", "createdById", description, icon, color,
|
||||||
permission, sharing, "maintainerApprovalRequired", index, "createdAt", "updatedAt"
|
permission, sharing, "maintainerApprovalRequired", index, sort, "createdAt", "updatedAt"
|
||||||
)
|
)
|
||||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, false, $10, NOW(), NOW())
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, false, $10, '{"field": "index", "direction": "asc"}', NOW(), NOW())
|
||||||
RETURNING
|
RETURNING
|
||||||
id, "urlId", name, description, icon, color, index, permission,
|
id, "urlId", name, description, icon, color, index, permission,
|
||||||
sharing, "teamId", "createdById", "createdAt", "updatedAt"
|
sharing, "teamId", "createdById", "createdAt", "updatedAt", sort
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await pool.query(query, [
|
const result = await pool.query(query, [
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import { BaseTool, ToolResponse, DocumentArgs, GetDocumentArgs, CreateDocumentArgs, UpdateDocumentArgs, SearchDocumentsArgs, MoveDocumentArgs } from '../types/tools.js';
|
import { BaseTool, ToolResponse, DocumentArgs, GetDocumentArgs, CreateDocumentArgs, UpdateDocumentArgs, SearchDocumentsArgs, MoveDocumentArgs } from '../types/tools.js';
|
||||||
import { validatePagination, validateSortDirection, validateSortField, isValidUUID, sanitizeInput } from '../utils/security.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
|
* 1. list_documents - Lista documentos publicados e drafts com filtros e paginação
|
||||||
@@ -236,21 +237,30 @@ const createDocument: BaseTool<CreateDocumentArgs> = {
|
|||||||
);
|
);
|
||||||
const teamId = teamResult.rows[0]?.teamId;
|
const teamId = teamResult.rows[0]?.teamId;
|
||||||
|
|
||||||
const query = `
|
// Use transaction to ensure both document and revision are created
|
||||||
|
await pgClient.query('BEGIN');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert Markdown to ProseMirror JSON
|
||||||
|
const proseMirrorContent = markdownToProseMirror(text);
|
||||||
|
|
||||||
|
const docQuery = `
|
||||||
INSERT INTO documents (
|
INSERT INTO documents (
|
||||||
id, "urlId", title, text, "collectionId", "teamId", "parentDocumentId", "createdById",
|
id, "urlId", title, text, "collectionId", "teamId", "parentDocumentId", "createdById",
|
||||||
"lastModifiedById", template, "publishedAt", "createdAt", "updatedAt", version,
|
"lastModifiedById", template, "publishedAt", "createdAt", "updatedAt", version,
|
||||||
"isWelcome", "fullWidth", "insightsEnabled"
|
"isWelcome", "fullWidth", "insightsEnabled", "collaboratorIds", "revisionCount", content,
|
||||||
|
"editorVersion"
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
gen_random_uuid(),
|
gen_random_uuid(),
|
||||||
substring(replace(gen_random_uuid()::text, '-', '') from 1 for 21),
|
substring(md5(random()::text) from 1 for 10),
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), 1, false, false, false
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), 1, false, false, false, ARRAY[$6]::uuid[],
|
||||||
|
1, $10::jsonb, '15.0.0'
|
||||||
)
|
)
|
||||||
RETURNING id, "urlId", title, "collectionId", "publishedAt", "createdAt"
|
RETURNING id, "urlId", title, "collectionId", "publishedAt", "createdAt"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const params = [
|
const docParams = [
|
||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
args.collection_id,
|
args.collection_id,
|
||||||
@@ -259,21 +269,65 @@ const createDocument: BaseTool<CreateDocumentArgs> = {
|
|||||||
userId,
|
userId,
|
||||||
userId,
|
userId,
|
||||||
args.template || false,
|
args.template || false,
|
||||||
publishedAt
|
publishedAt,
|
||||||
|
JSON.stringify(proseMirrorContent)
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = await pgClient.query(query, params);
|
const docResult = await pgClient.query(docQuery, docParams);
|
||||||
|
const newDoc = docResult.rows[0];
|
||||||
|
|
||||||
|
// Insert initial revision (required for Outline to display the document)
|
||||||
|
const revisionQuery = `
|
||||||
|
INSERT INTO revisions (
|
||||||
|
id, "documentId", "userId", title, text,
|
||||||
|
"createdAt", "updatedAt"
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
await pgClient.query(revisionQuery, [
|
||||||
|
newDoc.id,
|
||||||
|
userId,
|
||||||
|
title,
|
||||||
|
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 {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: JSON.stringify({
|
text: JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
document: result.rows[0],
|
document: newDoc,
|
||||||
message: args.publish ? 'Documento criado e publicado' : 'Draft criado (não publicado)'
|
message: args.publish ? 'Documento criado e publicado' : 'Draft criado (não publicado)'
|
||||||
}, null, 2)
|
}, null, 2)
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
} catch (txError) {
|
||||||
|
await pgClient.query('ROLLBACK');
|
||||||
|
throw txError;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
|
|||||||
383
src/utils/markdown-to-prosemirror.ts
Normal file
383
src/utils/markdown-to-prosemirror.ts
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
/**
|
||||||
|
* Markdown to ProseMirror JSON converter
|
||||||
|
* Converts basic Markdown to Outline's ProseMirror schema
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ProseMirrorNode {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, unknown>;
|
||||||
|
content?: ProseMirrorNode[];
|
||||||
|
text?: string;
|
||||||
|
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table
|
||||||
|
if (line.includes('|') && line.trim().startsWith('|')) {
|
||||||
|
const tableRows: string[][] = [];
|
||||||
|
|
||||||
|
// Collect all table rows
|
||||||
|
while (i < lines.length && lines[i].includes('|')) {
|
||||||
|
const row = lines[i].trim();
|
||||||
|
// Skip separator row (|---|---|)
|
||||||
|
if (/^\|[\s\-:]+\|/.test(row) && row.includes('-')) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Parse cells
|
||||||
|
const cells = row
|
||||||
|
.split('|')
|
||||||
|
.slice(1, -1) // Remove empty first and last from split
|
||||||
|
.map(cell => cell.trim());
|
||||||
|
if (cells.length > 0) {
|
||||||
|
tableRows.push(cells);
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableRows.length > 0) {
|
||||||
|
const numCols = Math.max(...tableRows.map(r => r.length));
|
||||||
|
const tableContent: ProseMirrorNode[] = tableRows.map((row, rowIndex) => {
|
||||||
|
// Process cells with content
|
||||||
|
const cells: ProseMirrorNode[] = row.map(cell => ({
|
||||||
|
type: rowIndex === 0 ? 'th' : 'td',
|
||||||
|
attrs: {
|
||||||
|
colspan: 1,
|
||||||
|
rowspan: 1,
|
||||||
|
alignment: null
|
||||||
|
},
|
||||||
|
content: [{ type: 'paragraph', content: parseInlineContent(cell) }]
|
||||||
|
}));
|
||||||
|
// Pad with empty cells if row is shorter
|
||||||
|
const padding = numCols - row.length;
|
||||||
|
for (let p = 0; p < padding; p++) {
|
||||||
|
cells.push({
|
||||||
|
type: rowIndex === 0 ? 'th' : 'td',
|
||||||
|
attrs: { colspan: 1, rowspan: 1, alignment: null },
|
||||||
|
content: [{ type: 'paragraph', content: [] }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { type: 'tr', content: cells };
|
||||||
|
});
|
||||||
|
|
||||||
|
content.push({
|
||||||
|
type: 'table',
|
||||||
|
content: tableContent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 (Outline uses "strong" not "bold")
|
||||||
|
const boldMatch = remaining.match(/^\*\*([^*]+)\*\*/) || remaining.match(/^__([^_]+)__/);
|
||||||
|
if (boldMatch) {
|
||||||
|
nodes.push({
|
||||||
|
type: 'text',
|
||||||
|
text: boldMatch[1],
|
||||||
|
marks: [{ type: 'strong' }]
|
||||||
|
});
|
||||||
|
remaining = remaining.slice(boldMatch[0].length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Italic (Outline uses "em" not "italic")
|
||||||
|
const italicMatch = remaining.match(/^\*([^*]+)\*/) || remaining.match(/^_([^_]+)_/);
|
||||||
|
if (italicMatch) {
|
||||||
|
nodes.push({
|
||||||
|
type: 'text',
|
||||||
|
text: italicMatch[1],
|
||||||
|
marks: [{ type: 'em' }]
|
||||||
|
});
|
||||||
|
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 '---';
|
||||||
|
|
||||||
|
case 'table':
|
||||||
|
const rows = (node.content || []).map((tr, rowIndex) => {
|
||||||
|
const cells = (tr.content || []).map(cell =>
|
||||||
|
contentToMarkdown(cell.content?.[0]?.content)
|
||||||
|
);
|
||||||
|
return '| ' + cells.join(' | ') + ' |';
|
||||||
|
});
|
||||||
|
// Add separator after header
|
||||||
|
if (rows.length > 0) {
|
||||||
|
const headerCells = (node.content?.[0]?.content || []).length;
|
||||||
|
const separator = '| ' + Array(headerCells).fill('---').join(' | ') + ' |';
|
||||||
|
rows.splice(1, 0, separator);
|
||||||
|
}
|
||||||
|
return rows.join('\n');
|
||||||
|
|
||||||
|
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':
|
||||||
|
case 'strong':
|
||||||
|
text = `**${text}**`;
|
||||||
|
break;
|
||||||
|
case 'italic':
|
||||||
|
case 'em':
|
||||||
|
text = `*${text}*`;
|
||||||
|
break;
|
||||||
|
case 'code_inline':
|
||||||
|
text = `\`${text}\``;
|
||||||
|
break;
|
||||||
|
case 'link':
|
||||||
|
text = `[${text}](${mark.attrs?.href})`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return nodeToMarkdown(node);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user