feat: Introduce markdown converter and auth utilities, alongside various client and server-side updates and dependency changes.
This commit is contained in:
202
src/index.ts
202
src/index.ts
@@ -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
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 {
|
||||
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 })),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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