chore: sync changes from canary to preview

This commit is contained in:
sriramveeraghanta
2025-12-11 14:11:28 +05:30
12 changed files with 588 additions and 126 deletions

View File

@@ -20,24 +20,32 @@ const fetchDocument = async ({ context, documentName: pageId, instance }: FetchP
try {
const service = getPageService(context.documentType, context);
// fetch details
const response = await service.fetchDescriptionBinary(pageId);
const response = (await service.fetchDescriptionBinary(pageId)) as Buffer;
const binaryData = new Uint8Array(response);
// if binary data is empty, convert HTML to binary data
if (binaryData.byteLength === 0) {
const pageDetails = await service.fetchDetails(pageId);
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "<p></p>");
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(
pageDetails.description_html ?? "<p></p>",
pageDetails.name
);
if (convertedBinaryData) {
// save the converted binary data back to the database
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
convertedBinaryData,
true
);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await service.updateDescriptionBinary(pageId, payload);
try {
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
convertedBinaryData,
true
);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await service.updateDescriptionBinary(pageId, payload);
} catch (e) {
const error = new AppError(e);
logger.error("Failed to save binary after first convertion from html:", error);
}
return convertedBinaryData;
}
}

View File

@@ -1,5 +1,13 @@
import { Database } from "./database";
import { ForceCloseHandler } from "./force-close-handler";
import { Logger } from "./logger";
import { Redis } from "./redis";
import { TitleSyncExtension } from "./title-sync";
export const getExtensions = () => [new Logger(), new Database(), new Redis()];
export const getExtensions = () => [
new Logger(),
new Database(),
new Redis(),
new TitleSyncExtension(),
new ForceCloseHandler(), // Must be after Redis to receive broadcasts
];

View File

@@ -1,18 +1,22 @@
// hocuspocus
import type { Extension, Hocuspocus, Document } from "@hocuspocus/server";
import { TiptapTransformer } from "@hocuspocus/transformer";
import type { AnyExtension, JSONContent } from "@tiptap/core";
import type * as Y from "yjs";
// editor extensions
import { TITLE_EDITOR_EXTENSIONS, createRealtimeEvent } from "@plane/editor";
import {
TITLE_EDITOR_EXTENSIONS,
createRealtimeEvent,
extractTextFromHTML,
generateTitleProsemirrorJson,
} from "@plane/editor";
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
// helpers
import { getPageService } from "@/services/page/handler";
import type { HocusPocusServerContext, OnLoadDocumentPayloadWithContext } from "@/types";
import { generateTitleProsemirrorJson } from "@/utils";
import { broadcastMessageToPage } from "@/utils/broadcast-message";
import { TitleUpdateManager } from "./title-update/title-update-manager";
import { extractTextFromHTML } from "./title-update/title-utils";
/**
* Hocuspocus extension for synchronizing document titles
@@ -41,15 +45,11 @@ export class TitleSyncExtension implements Extension {
// in the yjs binary
if (document.isEmpty("title")) {
const service = getPageService(context.documentType, context);
// const title = await service.fe
const title = (await service.fetchDetails?.(documentName)).name;
const pageDetails = await service.fetchDetails(documentName);
const title = pageDetails.name;
if (title == null) return;
const titleField = TiptapTransformer.toYdoc(
generateTitleProsemirrorJson(title),
"title",
// editor
TITLE_EDITOR_EXTENSIONS as any
);
const titleJson = (generateTitleProsemirrorJson as (text: string) => JSONContent)(title);
const titleField = TiptapTransformer.toYdoc(titleJson, "title", TITLE_EDITOR_EXTENSIONS as AnyExtension[]);
document.merge(titleField);
}
} catch (error) {

View File

@@ -1,21 +0,0 @@
export const generateTitleProsemirrorJson = (text: string) => {
return {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
...(text
? {
content: [
{
type: "text",
text,
},
],
}
: {}),
},
],
};
};

View File

@@ -1 +0,0 @@
export * from "./document";

View File

@@ -69,6 +69,7 @@ export const PageRoot = observer(function PageRoot(props: TPageRootProps) {
const { isFetchingFallbackBinary } = usePageFallback({
editorRef,
fetchPageDescription: handlers.fetchDescriptionBinary,
page,
collaborationState,
updatePageDescription: handlers.updateDescription,
});

View File

@@ -2,22 +2,22 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { EditorRefApi, CollaborationState } from "@plane/editor";
// plane editor
import { convertBinaryDataToBase64String, getBinaryDataFromDocumentEditorHTMLString } from "@plane/editor";
// plane propel
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
// plane types
import type { TDocumentPayload } from "@plane/types";
// hooks
import useAutoSave from "@/hooks/use-auto-save";
import type { TPageInstance } from "@/store/pages/base-page";
type TArgs = {
editorRef: React.RefObject<EditorRefApi>;
fetchPageDescription: () => Promise<ArrayBuffer>;
collaborationState: CollaborationState | null;
updatePageDescription: (data: TDocumentPayload) => Promise<void>;
page: TPageInstance;
};
export const usePageFallback = (args: TArgs) => {
const { editorRef, fetchPageDescription, collaborationState, updatePageDescription } = args;
const { editorRef, fetchPageDescription, collaborationState, updatePageDescription, page } = args;
const hasShownFallbackToast = useRef(false);
const [isFetchingFallbackBinary, setIsFetchingFallbackBinary] = useState(false);
@@ -32,12 +32,7 @@ export const usePageFallback = (args: TArgs) => {
// Show toast notification when fallback mechanism kicks in (only once)
if (!hasShownFallbackToast.current) {
// setToast({
// type: TOAST_TYPE.WARNING,
// title: "Connection lost",
// message: "Your changes are being saved using backup mechanism. ",
// });
console.log("Connection lost");
console.warn("Websocket Connection lost, your changes are being saved using backup mechanism.");
hasShownFallbackToast.current = true;
}
@@ -49,7 +44,11 @@ export const usePageFallback = (args: TArgs) => {
if (latestEncodedDescription && latestEncodedDescription.byteLength > 0) {
latestDecodedDescription = new Uint8Array(latestEncodedDescription);
} else {
latestDecodedDescription = getBinaryDataFromDocumentEditorHTMLString("<p></p>");
const pageDescriptionHtml = page.description_html;
latestDecodedDescription = getBinaryDataFromDocumentEditorHTMLString(
pageDescriptionHtml ?? "<p></p>",
page.name
);
}
editor.setProviderDocument(latestDecodedDescription);
@@ -64,15 +63,10 @@ export const usePageFallback = (args: TArgs) => {
});
} catch (error: any) {
console.error(error);
// setToast({
// type: TOAST_TYPE.ERROR,
// title: "Error",
// message: `Failed to update description using backup mechanism, ${error?.message}`,
// });
} finally {
setIsFetchingFallbackBinary(false);
}
}, [editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription]);
}, [editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription, page.description_html, page.name]);
useEffect(() => {
if (hasConnectionFailed) {

View File

@@ -1,5 +1,5 @@
import { Buffer } from "buffer";
import type { Extensions } from "@tiptap/core";
import type { Extensions, JSONContent } from "@tiptap/core";
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
@@ -69,16 +69,49 @@ export const getBinaryDataFromRichTextEditorHTMLString = (descriptionHTML: strin
return encodedData;
};
export const generateTitleProsemirrorJson = (text: string): JSONContent => {
return {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
...(text
? {
content: [
{
type: "text",
text,
},
],
}
: {}),
},
],
};
};
/**
* @description this function generates the binary equivalent of html content for the document editor
* @param {string} descriptionHTML
* @param {string} descriptionHTML - The HTML content to convert
* @param {string} [title] - Optional title to append to the document
* @returns {Uint8Array}
*/
export const getBinaryDataFromDocumentEditorHTMLString = (descriptionHTML: string): Uint8Array => {
export const getBinaryDataFromDocumentEditorHTMLString = (descriptionHTML: string, title?: string): Uint8Array => {
// convert HTML to JSON
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
// If title is provided, merge it into the document
if (title != null) {
const titleJSON = generateTitleProsemirrorJson(title);
const titleField = prosemirrorJSONToYDoc(documentEditorSchema, titleJSON, "title");
// Encode the title YDoc to updates and apply them to the main document
const titleUpdates = Y.encodeStateAsUpdate(titleField);
Y.applyUpdate(transformedData, titleUpdates);
}
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return encodedData;
@@ -207,8 +240,9 @@ export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllF
};
export const extractTextFromHTML = (html: string): string => {
// Use sanitizeHTML to safely extract text and remove all HTML tags
// Use DOMPurify to safely extract text and remove all HTML tags
// This is more secure than regex as it handles edge cases and prevents injection
// Note: sanitizeHTML trims whitespace, which is acceptable for title extraction
return sanitizeHTML(html) || "";
const sanitizedText = sanitizeHTML(html); // sanitize the string to remove all HTML tags
return sanitizedText.trim() || ""; // trim the string to remove leading and trailing whitespaces
};

View File

@@ -187,7 +187,7 @@ export const useYjsSetup = ({ docId, serverUrl, authToken, onStateChange }: UseY
provider.on("close", handleClose);
setYjsSession({ provider, ydoc: provider.document });
setYjsSession({ provider, ydoc: provider.document as Y.Doc });
// Handle page visibility changes (sleep/wake, tab switching)
const handleVisibilityChange = (event?: Event) => {

View File

@@ -27,7 +27,6 @@
"@plane/types": "workspace:*",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "3.2.7",
"hast": "^1.0.0",
"hast-util-to-mdast": "^10.1.2",
"lodash-es": "catalog:",
@@ -38,6 +37,7 @@
"rehype-remark": "^10.0.1",
"remark-gfm": "^4.0.1",
"remark-stringify": "^11.0.0",
"sanitize-html": "2.17.0",
"tailwind-merge": "^2.5.5",
"unified": "^11.0.5",
"uuid": "catalog:"
@@ -49,6 +49,7 @@
"@types/mdast": "^4.0.4",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/sanitize-html": "2.16.0",
"tsdown": "catalog:",
"typescript": "catalog:"
},

View File

@@ -1,4 +1,4 @@
import DOMPurify from "dompurify";
import sanitizeHtml from "sanitize-html";
import type { Content, JSONContent } from "@plane/types";
/**
@@ -120,7 +120,7 @@ const text = stripHTML(html);
console.log(text); // Some text
*/
export const sanitizeHTML = (htmlString: string) => {
const sanitizedText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: [] }); // sanitize the string to remove all HTML tags
const sanitizedText = sanitizeHtml(htmlString, { allowedTags: [] }); // sanitize the string to remove all HTML tags
return sanitizedText.trim(); // trim the string to remove leading and trailing whitespaces
};
@@ -155,8 +155,8 @@ export const checkEmailValidity = (email: string): boolean => {
};
export const isEmptyHtmlString = (htmlString: string, allowedHTMLTags: string[] = []) => {
// Remove HTML tags using DOMPurify
const cleanText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: allowedHTMLTags });
// Remove HTML tags using sanitize-html
const cleanText = sanitizeHtml(htmlString, { allowedTags: allowedHTMLTags });
// Trim the string and check if it's empty
return cleanText.trim() === "";
};

544
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff