feat: Introduce markdown converter and auth utilities, alongside various client and server-side updates and dependency changes.
This commit is contained in:
17
README.md
17
README.md
@@ -7,12 +7,7 @@ A Model Context Protocol (MCP) server for [Docmost](https://docmost.com/), enabl
|
|||||||
### Core Management
|
### Core Management
|
||||||
|
|
||||||
- **`create_page`**: Smart creation tool. Creates content (via import) AND handles hierarchy (nesting under a parent) in one go.
|
- **`create_page`**: Smart creation tool. Creates content (via import) AND handles hierarchy (nesting under a parent) in one go.
|
||||||
- **`update_page`**: ⚠️ **IMPORTANT**: This tool **recreates the page** with a **new ID** and **deletes the old page**. While it preserves child pages and hierarchy, **all references to the old page ID will break**, including:
|
- **`update_page`**: Update a page's content and/or title. Updates are performed via real-time collaboration (WebSocket).
|
||||||
- Comments and discussions
|
|
||||||
- Page history/changelog
|
|
||||||
|
|
||||||
This is a workaround limitation due to Docmost's API not providing a native update endpoint. Use with caution!
|
|
||||||
|
|
||||||
- **`delete_page` / `delete_pages`**: Delete single or multiple pages at once.
|
- **`delete_page` / `delete_pages`**: Delete single or multiple pages at once.
|
||||||
- **`move_page`**: Organize pages hierarchically by moving them to a new parent or root.
|
- **`move_page`**: Organize pages hierarchically by moving them to a new parent or root.
|
||||||
|
|
||||||
@@ -55,13 +50,13 @@ Add the following to your MCP configuration (e.g. `claude_desktop_config.json`):
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"docmost": {
|
"docmost-local": {
|
||||||
"command": "node",
|
"command": "node",
|
||||||
"args": ["/path/to/docmost-mcp/build/index.js"],
|
"args": ["./build/index.js"],
|
||||||
"env": {
|
"env": {
|
||||||
"DOCMOST_API_URL": "https://your-docmost-instance.com/api",
|
"DOCMOST_API_URL": "http://localhost:3000/api",
|
||||||
"DOCMOST_EMAIL": "your-email@example.com",
|
"DOCMOST_EMAIL": "test@docmost.com",
|
||||||
"DOCMOST_PASSWORD": "your-password"
|
"DOCMOST_PASSWORD": "test"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1524
package-lock.json
generated
1524
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -23,9 +23,21 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hocuspocus/provider": "^3.4.4",
|
||||||
|
"@hocuspocus/transformer": "^3.4.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.25.3",
|
"@modelcontextprotocol/sdk": "^1.25.3",
|
||||||
|
"@tiptap/core": "^3.18.0",
|
||||||
|
"@tiptap/extension-image": "^3.18.0",
|
||||||
|
"@tiptap/extension-link": "^3.18.0",
|
||||||
|
"@tiptap/html": "^3.18.0",
|
||||||
|
"@tiptap/starter-kit": "^3.18.0",
|
||||||
|
"@types/jsdom": "^27.0.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.0",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
|
"marked": "^17.0.1",
|
||||||
|
"ws": "^8.19.0",
|
||||||
|
"yjs": "^13.6.29",
|
||||||
"zod": "^3.22.0"
|
"zod": "^3.22.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
200
src/index.ts
200
src/index.ts
@@ -9,11 +9,13 @@ import {
|
|||||||
filterGroup,
|
filterGroup,
|
||||||
filterPage,
|
filterPage,
|
||||||
filterSearchResult,
|
filterSearchResult,
|
||||||
} from "./filters.js";
|
} from "./lib/filters.js";
|
||||||
import { convertProseMirrorToMarkdown } from "./markdown-converter.js";
|
import { convertProseMirrorToMarkdown } from "./lib/markdown-converter.js";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
|
import { updatePageContentRealtime } from "./lib/collaboration.js";
|
||||||
|
import { getCollabToken, performLogin } from "./lib/auth-utils.js";
|
||||||
|
|
||||||
// Read version from package.json
|
// Read version from package.json
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -49,33 +51,16 @@ class DocmostClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async login() {
|
async login() {
|
||||||
try {
|
if (!EMAIL || !PASSWORD) {
|
||||||
const response = await this.client.post("/auth/login", {
|
throw new Error("Missing Credentials (DOCMOST_EMAIL, DOCMOST_PASSWORD)");
|
||||||
email: EMAIL,
|
|
||||||
password: PASSWORD,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract token from Set-Cookie header
|
|
||||||
const cookies = response.headers["set-cookie"];
|
|
||||||
if (!cookies) {
|
|
||||||
throw new Error("No Set-Cookie header found in login response");
|
|
||||||
}
|
|
||||||
const authCookie = cookies.find((c: string) =>
|
|
||||||
c.startsWith("authToken="),
|
|
||||||
);
|
|
||||||
if (!authCookie) {
|
|
||||||
throw new Error("No authToken cookie found in login response");
|
|
||||||
}
|
}
|
||||||
|
// baseURL is already set in this.client
|
||||||
|
const baseURL = this.client.defaults.baseURL || "";
|
||||||
|
|
||||||
const token = authCookie.split(";")[0].split("=")[1];
|
// Use shared auth utility
|
||||||
|
this.token = await performLogin(baseURL, EMAIL, PASSWORD);
|
||||||
this.token = token;
|
this.client.defaults.headers.common["Authorization"] =
|
||||||
this.client.defaults.headers.common["Authorization"] = `Bearer ${token}`;
|
`Bearer ${this.token}`;
|
||||||
// console.error("Successfully logged in to Docmost API");
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Login failed:", error.response?.data || error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureAuthenticated() {
|
async ensureAuthenticated() {
|
||||||
@@ -151,16 +136,48 @@ class DocmostClient {
|
|||||||
return pages.map((page) => filterPage(page));
|
return pages.map((page) => filterPage(page));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listSidebarPages(spaceId: string, pageId: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const response = await this.client.post("/pages/sidebar-pages", {
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
return response.data?.data?.items || [];
|
||||||
|
}
|
||||||
|
|
||||||
async getPage(pageId: string) {
|
async getPage(pageId: string) {
|
||||||
await this.ensureAuthenticated();
|
await this.ensureAuthenticated();
|
||||||
const response = await this.client.post("/pages/info", { pageId });
|
const response = await this.client.post("/pages/info", { pageId });
|
||||||
|
const resultData = response.data.data; // Assuming data is nested under 'data'
|
||||||
|
|
||||||
|
let content = resultData.content
|
||||||
|
? convertProseMirrorToMarkdown(resultData.content)
|
||||||
|
: ""; // Default to empty string
|
||||||
|
|
||||||
|
// Always fetch subpages to provide context to the agent
|
||||||
|
let subpages: any[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
subpages = await this.listSidebarPages(resultData.spaceId, pageId);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn("Failed to fetch subpages:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve subpages if the placeholder exists
|
||||||
|
if (content && content.includes("{{SUBPAGES}}")) {
|
||||||
|
if (subpages && subpages.length > 0) {
|
||||||
|
const list = subpages
|
||||||
|
.map((p: any) => `- [${p.title}](page:${p.id})`)
|
||||||
|
.join("\n");
|
||||||
|
content = content.replace("{{SUBPAGES}}", `### Subpages\n${list}`);
|
||||||
|
} else {
|
||||||
|
content = content.replace("{{SUBPAGES}}", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: filterPage(
|
data: filterPage(resultData, content, subpages),
|
||||||
response.data.data,
|
|
||||||
response.data.data.content
|
|
||||||
? convertProseMirrorToMarkdown(response.data.data.content)
|
|
||||||
: undefined,
|
|
||||||
),
|
|
||||||
success: response.data.success,
|
success: response.data.success,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -190,9 +207,26 @@ class DocmostClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Create content via Import
|
// 1. Create content via Import (using multipart/form-data)
|
||||||
const importRes = await this.importPage(title, content, spaceId);
|
const form = new FormData();
|
||||||
const newPageId = importRes.data.id;
|
form.append("spaceId", spaceId);
|
||||||
|
|
||||||
|
const fileContent = Buffer.from(content, "utf-8");
|
||||||
|
form.append("file", fileContent, {
|
||||||
|
filename: `${title || "import"}.md`,
|
||||||
|
contentType: "text/markdown",
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...form.getHeaders(),
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use raw axios call for FormData handling
|
||||||
|
const response = await axios.post(`${API_URL}/pages/import`, form, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
const newPageId = response.data.data.id;
|
||||||
|
|
||||||
// 2. Move to parent if needed
|
// 2. Move to parent if needed
|
||||||
if (parentPageId) {
|
if (parentPageId) {
|
||||||
@@ -205,82 +239,43 @@ class DocmostClient {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a page's content and optionally its title.
|
* Update a page's content and optionally its title.
|
||||||
*
|
* Leverages WebSocket collaboration to update content without changing Page ID.
|
||||||
* Note: As long as Docmost doesn't provide a /pages/update endpoint, we must use
|
|
||||||
* a "swap-and-replace" workaround to update page content. This method:
|
|
||||||
* 1. Fetches the old page details (space, parent, title)
|
|
||||||
* 2. Identifies all child pages
|
|
||||||
* 3. Creates a NEW page with the updated content via /pages/import
|
|
||||||
* 4. Moves the new page to the old page's position (same parent)
|
|
||||||
* 5. Re-parents all children to the new page
|
|
||||||
* 6. Deletes the old page
|
|
||||||
*
|
|
||||||
* ⚠️ IMPORTANT LIMITATION: The page will get a NEW ID! Any external references
|
|
||||||
* to the old page ID will break. The new ID is returned in the response.
|
|
||||||
*/
|
*/
|
||||||
async updatePage(pageId: string, content: string, title?: string) {
|
async updatePage(pageId: string, content: string, title?: string) {
|
||||||
await this.ensureAuthenticated();
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
// 1. Get old page details to know space and parent
|
// 1. Update Title via REST API if provided
|
||||||
const oldPageRes = await this.getPage(pageId);
|
if (title) {
|
||||||
const oldPage = oldPageRes.data;
|
await this.client.post("/pages/update", { pageId, title });
|
||||||
const spaceId = oldPage.spaceId;
|
|
||||||
const parentPageId = oldPage.parentPageId;
|
|
||||||
const effectiveTitle = title || oldPage.title || "Untitled";
|
|
||||||
|
|
||||||
// 2. Identify Children
|
|
||||||
const allPages = await this.listPages(spaceId);
|
|
||||||
const children = allPages.filter((p: any) => p.parentPageId === pageId);
|
|
||||||
|
|
||||||
// 3. Create NEW page
|
|
||||||
const importRes = await this.importPage(effectiveTitle, content, spaceId);
|
|
||||||
const newPageId = importRes.data.id;
|
|
||||||
|
|
||||||
// 4. Move NEW page to OLD page's position (same parent)
|
|
||||||
if (parentPageId) {
|
|
||||||
await this.movePage(newPageId, parentPageId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Reparent Children (Rescue them!)
|
// 2. Update Content via WebSocket
|
||||||
for (const child of children) {
|
let collabToken = "";
|
||||||
await this.movePage(child.id, newPageId);
|
try {
|
||||||
|
const baseURL = this.client.defaults.baseURL || "";
|
||||||
|
collabToken = await getCollabToken(baseURL, this.token!);
|
||||||
|
await updatePageContentRealtime(pageId, content, collabToken, baseURL);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
"Failed to update page content via realtime collaboration:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
const tokenPreview = collabToken
|
||||||
|
? collabToken.substring(0, 15) + "..."
|
||||||
|
: "null";
|
||||||
|
throw new Error(
|
||||||
|
`Failed to update page content: ${error.message} (Token: ${tokenPreview})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Delete OLD page
|
|
||||||
await this.deletePage(pageId);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
newMessage:
|
modified: true,
|
||||||
"Page updated safely (via swap-and-replace). Children preserved.",
|
message: "Page updated successfully.",
|
||||||
newPageId: newPageId,
|
pageId: pageId,
|
||||||
preservedChildrenCount: children.length,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async importPage(title: string, content: string, spaceId: string) {
|
|
||||||
await this.ensureAuthenticated();
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
form.append("spaceId", spaceId);
|
|
||||||
|
|
||||||
// We create a virtual file
|
|
||||||
const fileContent = Buffer.from(content, "utf-8");
|
|
||||||
form.append("file", fileContent, {
|
|
||||||
filename: `${title || "import"}.md`,
|
|
||||||
contentType: "text/markdown",
|
|
||||||
});
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
...form.getHeaders(),
|
|
||||||
Authorization: `Bearer ${this.token}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
return axios
|
|
||||||
.post(`${API_URL}/pages/import`, form, { headers })
|
|
||||||
.then((res) => res.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async search(query: string, spaceId?: string) {
|
async search(query: string, spaceId?: string) {
|
||||||
await this.ensureAuthenticated();
|
await this.ensureAuthenticated();
|
||||||
const response = await this.client.post("/search", {
|
const response = await this.client.post("/search", {
|
||||||
@@ -447,14 +442,11 @@ server.registerTool(
|
|||||||
"update_page",
|
"update_page",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Update a page's content safely (preserves child pages by moving them). Note: Returns a NEW pageId.",
|
"Update a page's content and/or title via realtime collaboration (preserves Page ID and history).",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
pageId: z.string().describe("ID of the page to update"),
|
pageId: z.string().describe("ID of the page to update"),
|
||||||
content: z.string().describe("New Markdown content"),
|
content: z.string().describe("New Markdown content"),
|
||||||
title: z
|
title: z.string().optional().describe("Optional new title"),
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Optional new title (defaults to old title)"),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ pageId, content, title }) => {
|
async ({ pageId, content, title }) => {
|
||||||
|
|||||||
62
src/lib/auth-utils.ts
Normal file
62
src/lib/auth-utils.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export async function getCollabToken(
|
||||||
|
baseUrl: string,
|
||||||
|
apiToken: string,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${baseUrl}/auth/collab-token`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// console.error('Collab Token Response:', response.data);
|
||||||
|
// Response is wrapped in { data: { token: ... } }
|
||||||
|
return response.data.data?.token || response.data.token;
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to get collab token: ${error.response?.status} ${error.response?.statusText} - ${JSON.stringify(error.response?.data)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function performLogin(
|
||||||
|
baseUrl: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${baseUrl}/auth/login`, {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract token from Set-Cookie header
|
||||||
|
const cookies = response.headers["set-cookie"];
|
||||||
|
if (!cookies) {
|
||||||
|
throw new Error("No Set-Cookie header found in login response");
|
||||||
|
}
|
||||||
|
const authCookie = cookies.find((c: string) => c.startsWith("authToken="));
|
||||||
|
if (!authCookie) {
|
||||||
|
throw new Error("No authToken cookie found in login response");
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authCookie.split(";")[0].split("=")[1];
|
||||||
|
return token;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
"Login failed:",
|
||||||
|
axios.isAxiosError(error) ? error.response?.data : error.message,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/lib/collaboration.ts
Normal file
138
src/lib/collaboration.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
|
import { TiptapTransformer } from "@hocuspocus/transformer";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
import { marked } from "marked";
|
||||||
|
import { generateJSON } from "@tiptap/html";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import { tiptapExtensions } from "./tiptap-extensions.js";
|
||||||
|
|
||||||
|
// Setup DOM environment for Tiptap HTML parsing in Node.js
|
||||||
|
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
|
||||||
|
global.window = dom.window as any;
|
||||||
|
global.document = dom.window.document;
|
||||||
|
// @ts-ignore
|
||||||
|
global.Element = dom.window.Element;
|
||||||
|
// @ts-ignore
|
||||||
|
global.WebSocket = WebSocket;
|
||||||
|
// Navigator is read-only in newer Node versions and already exists
|
||||||
|
// global.navigator = dom.window.navigator;
|
||||||
|
|
||||||
|
export async function updatePageContentRealtime(
|
||||||
|
pageId: string,
|
||||||
|
markdownContent: string,
|
||||||
|
collabToken: string,
|
||||||
|
baseUrl: string,
|
||||||
|
): Promise<void> {
|
||||||
|
console.error(`Starting realtime update for page ${pageId}`);
|
||||||
|
console.error(
|
||||||
|
`Token prefix: ${collabToken ? collabToken.substring(0, 5) : "NONE"}...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Convert Markdown to HTML
|
||||||
|
const html = await marked.parse(markdownContent);
|
||||||
|
|
||||||
|
// 2. Convert HTML to ProseMirror JSON
|
||||||
|
const tiptapJson = generateJSON(html, tiptapExtensions);
|
||||||
|
|
||||||
|
// 3. Setup Hocuspocus Provider
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
|
||||||
|
// Construct WebSocket URL
|
||||||
|
// Replace protocol
|
||||||
|
let wsUrl = baseUrl.replace(/^http/, "ws");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(wsUrl);
|
||||||
|
// Remove /api suffix if present, as the websocket is mounted on root /collab
|
||||||
|
if (urlObj.pathname.endsWith("/api") || urlObj.pathname.endsWith("/api/")) {
|
||||||
|
urlObj.pathname = urlObj.pathname.replace(/\/api\/?$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set correct path to /collab
|
||||||
|
urlObj.pathname = urlObj.pathname.replace(/\/$/, "") + "/collab";
|
||||||
|
|
||||||
|
wsUrl = urlObj.toString();
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback if URL parsing fails
|
||||||
|
if (!wsUrl.endsWith("/collab")) {
|
||||||
|
wsUrl = wsUrl.replace(/\/$/, "") + "/collab";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Connecting to WebSocket: ${wsUrl}`);
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
// Safety timeout
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (provider) provider.destroy();
|
||||||
|
reject(new Error("Connection timeout to collaboration server"));
|
||||||
|
}, 25000);
|
||||||
|
|
||||||
|
const provider = new HocuspocusProvider({
|
||||||
|
url: wsUrl,
|
||||||
|
name: `page.${pageId}`,
|
||||||
|
document: ydoc,
|
||||||
|
token: collabToken,
|
||||||
|
// @ts-ignore - Required for Node.js environment
|
||||||
|
WebSocketPolyfill: WebSocket as any,
|
||||||
|
onConnect: () => console.error("WS Connect"),
|
||||||
|
onDisconnect: (data) => console.error("WS Disconnect"),
|
||||||
|
onClose: (data) => console.error("WS Close"),
|
||||||
|
onSynced: () => {
|
||||||
|
console.error("Connected and synced!");
|
||||||
|
try {
|
||||||
|
// Prepare the new content in a separate doc
|
||||||
|
const tempDoc = TiptapTransformer.toYdoc(
|
||||||
|
tiptapJson,
|
||||||
|
"default",
|
||||||
|
tiptapExtensions,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply update
|
||||||
|
ydoc.transact(() => {
|
||||||
|
const fragment = ydoc.getXmlFragment("default");
|
||||||
|
// 1. Clear existing content
|
||||||
|
if (fragment.length > 0) {
|
||||||
|
fragment.delete(0, fragment.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Apply new content from tempDoc
|
||||||
|
// Note: applyUpdate adds content. Since we cleared, it should effectively replace.
|
||||||
|
// However, applyUpdate merges structures based on IDs. tempDoc has new IDs.
|
||||||
|
const update = Y.encodeStateAsUpdate(tempDoc);
|
||||||
|
Y.applyUpdate(ydoc, update);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
"Content replaced. Returning success to user immediately (Background persistence)...",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear safety timeout as we are successful
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
// Resolve immediately so the user doesn't have to wait
|
||||||
|
resolve();
|
||||||
|
|
||||||
|
// Keep connection open in background for save/sync (Docmost has 10s debounce)
|
||||||
|
// The node process will keep running this timeout even after the tool returns
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
console.error(`Closing background connection for page ${pageId}`);
|
||||||
|
provider.destroy();
|
||||||
|
} catch (err) {}
|
||||||
|
}, 15000);
|
||||||
|
} catch (e) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
provider.destroy();
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onAuthenticationFailed: () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
provider.destroy();
|
||||||
|
reject(new Error("Authentication failed for collaboration connection"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ export function filterGroup(group: any) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterPage(page: any, content?: string) {
|
export function filterPage(page: any, content?: string, subpages?: any[]) {
|
||||||
return {
|
return {
|
||||||
id: page.id,
|
id: page.id,
|
||||||
title: page.title,
|
title: page.title,
|
||||||
@@ -50,8 +50,13 @@ export function filterPage(page: any, content?: string) {
|
|||||||
createdAt: page.createdAt,
|
createdAt: page.createdAt,
|
||||||
updatedAt: page.updatedAt,
|
updatedAt: page.updatedAt,
|
||||||
deletedAt: page.deletedAt,
|
deletedAt: page.deletedAt,
|
||||||
// Include converted markdown content if provided
|
// Include converted markdown content if valid string (even empty)
|
||||||
...(content && { content }),
|
...(typeof content === "string" && { content }),
|
||||||
|
// Include subpages if provided
|
||||||
|
...(subpages &&
|
||||||
|
subpages.length > 0 && {
|
||||||
|
subpages: subpages.map((p) => ({ id: p.id, title: p.title })),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
|||||||
case "callout":
|
case "callout":
|
||||||
const calloutType = node.attrs?.type || "info";
|
const calloutType = node.attrs?.type || "info";
|
||||||
const calloutContent = nodeContent.map(processNode).join("\n");
|
const calloutContent = nodeContent.map(processNode).join("\n");
|
||||||
return `> **${calloutType.toUpperCase()}**\n> ${calloutContent.replace(/\n/g, "\n> ")}`;
|
return `:::${calloutType.toLowerCase()}\n${calloutContent}\n:::`;
|
||||||
|
|
||||||
case "details":
|
case "details":
|
||||||
return nodeContent.map(processNode).join("\n");
|
return nodeContent.map(processNode).join("\n");
|
||||||
@@ -148,11 +148,11 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
|||||||
return `${detailsText}\n</details>`;
|
return `${detailsText}\n</details>`;
|
||||||
|
|
||||||
case "mathInline":
|
case "mathInline":
|
||||||
const inlineMath = node.attrs?.latex || "";
|
const inlineMath = node.attrs?.text || "";
|
||||||
return `$${inlineMath}$`;
|
return `$${inlineMath}$`;
|
||||||
|
|
||||||
case "mathBlock":
|
case "mathBlock":
|
||||||
const blockMath = node.attrs?.latex || "";
|
const blockMath = node.attrs?.text || "";
|
||||||
return `$$\n${blockMath}\n$$`;
|
return `$$\n${blockMath}\n$$`;
|
||||||
|
|
||||||
case "mention":
|
case "mention":
|
||||||
@@ -175,7 +175,7 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
|||||||
return `🔗 [Embedded Content](${embedUrl})`;
|
return `🔗 [Embedded Content](${embedUrl})`;
|
||||||
|
|
||||||
case "subpages":
|
case "subpages":
|
||||||
return `📑 [Subpages List]`;
|
return "{{SUBPAGES}}";
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Fallback: process children
|
// Fallback: process children
|
||||||
19
src/lib/tiptap-extensions.ts
Normal file
19
src/lib/tiptap-extensions.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import Image from "@tiptap/extension-image";
|
||||||
|
import Link from "@tiptap/extension-link";
|
||||||
|
|
||||||
|
// Define extensions compatible with standard Markdown features
|
||||||
|
// We use the default Tiptap extensions to handle basic content
|
||||||
|
export const tiptapExtensions = [
|
||||||
|
StarterKit.configure({
|
||||||
|
// Explicitly enable features that might be disabled in some contexts
|
||||||
|
codeBlock: {},
|
||||||
|
heading: {},
|
||||||
|
}),
|
||||||
|
Image.configure({
|
||||||
|
inline: true,
|
||||||
|
}),
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
}),
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user