feat: Introduce markdown converter and auth utilities, alongside various client and server-side updates and dependency changes.

This commit is contained in:
Moritz Krause
2026-02-01 04:24:41 +01:00
parent 0900260765
commit 493ce01254
9 changed files with 1868 additions and 125 deletions

View File

@@ -9,11 +9,13 @@ import {
filterGroup,
filterPage,
filterSearchResult,
} from "./filters.js";
import { convertProseMirrorToMarkdown } from "./markdown-converter.js";
} from "./lib/filters.js";
import { convertProseMirrorToMarkdown } from "./lib/markdown-converter.js";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { updatePageContentRealtime } from "./lib/collaboration.js";
import { getCollabToken, performLogin } from "./lib/auth-utils.js";
// Read version from package.json
const __filename = fileURLToPath(import.meta.url);
@@ -49,33 +51,16 @@ class DocmostClient {
}
async login() {
try {
const response = await this.client.post("/auth/login", {
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");
}
const token = authCookie.split(";")[0].split("=")[1];
this.token = token;
this.client.defaults.headers.common["Authorization"] = `Bearer ${token}`;
// console.error("Successfully logged in to Docmost API");
} catch (error: any) {
console.error("Login failed:", error.response?.data || error.message);
throw error;
if (!EMAIL || !PASSWORD) {
throw new Error("Missing Credentials (DOCMOST_EMAIL, DOCMOST_PASSWORD)");
}
// baseURL is already set in this.client
const baseURL = this.client.defaults.baseURL || "";
// Use shared auth utility
this.token = await performLogin(baseURL, EMAIL, PASSWORD);
this.client.defaults.headers.common["Authorization"] =
`Bearer ${this.token}`;
}
async ensureAuthenticated() {
@@ -151,16 +136,48 @@ class DocmostClient {
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) {
await this.ensureAuthenticated();
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 {
data: filterPage(
response.data.data,
response.data.data.content
? convertProseMirrorToMarkdown(response.data.data.content)
: undefined,
),
data: filterPage(resultData, content, subpages),
success: response.data.success,
};
}
@@ -190,9 +207,26 @@ class DocmostClient {
}
}
// 1. Create content via Import
const importRes = await this.importPage(title, content, spaceId);
const newPageId = importRes.data.id;
// 1. Create content via Import (using multipart/form-data)
const form = new FormData();
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
if (parentPageId) {
@@ -205,82 +239,43 @@ class DocmostClient {
/**
* Update a page's content and optionally its title.
*
* 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.
* Leverages WebSocket collaboration to update content without changing Page ID.
*/
async updatePage(pageId: string, content: string, title?: string) {
await this.ensureAuthenticated();
// 1. Get old page details to know space and parent
const oldPageRes = await this.getPage(pageId);
const oldPage = oldPageRes.data;
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);
// 1. Update Title via REST API if provided
if (title) {
await this.client.post("/pages/update", { pageId, title });
}
// 5. Reparent Children (Rescue them!)
for (const child of children) {
await this.movePage(child.id, newPageId);
// 2. Update Content via WebSocket
let collabToken = "";
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 {
success: true,
newMessage:
"Page updated safely (via swap-and-replace). Children preserved.",
newPageId: newPageId,
preservedChildrenCount: children.length,
modified: true,
message: "Page updated successfully.",
pageId: pageId,
};
}
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) {
await this.ensureAuthenticated();
const response = await this.client.post("/search", {
@@ -447,14 +442,11 @@ server.registerTool(
"update_page",
{
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: {
pageId: z.string().describe("ID of the page to update"),
content: z.string().describe("New Markdown content"),
title: z
.string()
.optional()
.describe("Optional new title (defaults to old title)"),
title: z.string().optional().describe("Optional new title"),
},
},
async ({ pageId, content, title }) => {

62
src/lib/auth-utils.ts Normal file
View 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
View 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"));
},
});
});
}

View File

@@ -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 {
id: page.id,
title: page.title,
@@ -50,8 +50,13 @@ export function filterPage(page: any, content?: string) {
createdAt: page.createdAt,
updatedAt: page.updatedAt,
deletedAt: page.deletedAt,
// Include converted markdown content if provided
...(content && { content }),
// Include converted markdown content if valid string (even empty)
...(typeof content === "string" && { content }),
// Include subpages if provided
...(subpages &&
subpages.length > 0 && {
subpages: subpages.map((p) => ({ id: p.id, title: p.title })),
}),
};
}

View File

@@ -134,7 +134,7 @@ export function convertProseMirrorToMarkdown(content: any): string {
case "callout":
const calloutType = node.attrs?.type || "info";
const calloutContent = nodeContent.map(processNode).join("\n");
return `> **${calloutType.toUpperCase()}**\n> ${calloutContent.replace(/\n/g, "\n> ")}`;
return `:::${calloutType.toLowerCase()}\n${calloutContent}\n:::`;
case "details":
return nodeContent.map(processNode).join("\n");
@@ -148,11 +148,11 @@ export function convertProseMirrorToMarkdown(content: any): string {
return `${detailsText}\n</details>`;
case "mathInline":
const inlineMath = node.attrs?.latex || "";
const inlineMath = node.attrs?.text || "";
return `$${inlineMath}$`;
case "mathBlock":
const blockMath = node.attrs?.latex || "";
const blockMath = node.attrs?.text || "";
return `$$\n${blockMath}\n$$`;
case "mention":
@@ -175,7 +175,7 @@ export function convertProseMirrorToMarkdown(content: any): string {
return `🔗 [Embedded Content](${embedUrl})`;
case "subpages":
return `📑 [Subpages List]`;
return "{{SUBPAGES}}";
default:
// Fallback: process children

View 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,
}),
];