diff --git a/apps/api/plane/app/urls/asset.py b/apps/api/plane/app/urls/asset.py index a9fa44a447..1c3ab886c5 100644 --- a/apps/api/plane/app/urls/asset.py +++ b/apps/api/plane/app/urls/asset.py @@ -19,6 +19,10 @@ from plane.app.views import ( ProjectAssetDownloadEndpoint, ProxyUploadEndpoint, ProxyDownloadEndpoint, + ProjectReuploadAssetEndpoint, + WorkspaceReuploadAssetEndpoint, + WorkspaceFileAssetServerEndpoint, + ProjectAssetServerEndpoint, ) @@ -55,6 +59,11 @@ urlpatterns = [ WorkspaceFileAssetEndpoint.as_view(), name="workspace-file-assets", ), + path( + "assets/v2/workspaces//reupload//", + WorkspaceReuploadAssetEndpoint.as_view(), + name="workspace-reupload-asset", + ), path( "assets/v2/user-assets/", UserAssetsV2Endpoint.as_view(), @@ -80,6 +89,11 @@ urlpatterns = [ ProjectAssetEndpoint.as_view(), name="bulk-asset-update", ), + path( + "assets/v2/workspaces//projects//reupload//", + ProjectReuploadAssetEndpoint.as_view(), + name="bulk-asset-reupload", + ), path( "assets/v2/workspaces//projects///", ProjectAssetEndpoint.as_view(), @@ -125,4 +139,14 @@ urlpatterns = [ ProxyDownloadEndpoint.as_view(), name="proxy-download", ), + path( + "assets/v2/workspaces///server/", + WorkspaceFileAssetServerEndpoint.as_view(), + name="workspace-file-asset-server", + ), + path( + "assets/v2/workspaces//projects///server/", + ProjectAssetServerEndpoint.as_view(), + name="project-asset-server", + ), ] diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index dc5c80d497..549177bd8f 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -108,6 +108,10 @@ from .asset.v2 import ( AssetCheckEndpoint, WorkspaceAssetDownloadEndpoint, ProjectAssetDownloadEndpoint, + ProjectReuploadAssetEndpoint, + WorkspaceReuploadAssetEndpoint, + WorkspaceFileAssetServerEndpoint, + ProjectAssetServerEndpoint, ) from .asset.silo import SiloAssetsEndpoint diff --git a/apps/api/plane/app/views/asset/v2.py b/apps/api/plane/app/views/asset/v2.py index 8fa8136749..289e72cfbb 100644 --- a/apps/api/plane/app/views/asset/v2.py +++ b/apps/api/plane/app/views/asset/v2.py @@ -515,6 +515,67 @@ class WorkspaceFileAssetEndpoint(BaseAPIView): return HttpResponseRedirect(signed_url) +class WorkspaceReuploadAssetEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def post(self, request, slug, asset_id): + file_type = request.data.get("type", "image/jpeg") + file_size = request.data.get("size") + + if not file_size: + return Response( + {"error": "Missing required 'size' parameter"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + file_size = int(file_size) + except (ValueError, TypeError): + return Response( + {"error": "Invalid size parameter"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Find asset in workspace or project within workspace + try: + from django.db.models import Q + asset = FileAsset.objects.get( + Q(workspace__slug=slug), + id=asset_id + ) + except FileAsset.DoesNotExist: + return Response( + {"error": f"Asset with ID {asset_id} does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not asset.asset or not asset.asset.name: + return Response( + {"error": "Asset has no associated file"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + storage = S3Storage(request=request) + presigned_url = storage.generate_presigned_post( + object_name=asset.asset.name, + file_type=file_type, + file_size=min(file_size, settings.FILE_SIZE_LIMIT) + ) + + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + except Exception as e: + return Response( + {"error": f"Error generating upload URL: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + class StaticFileAssetEndpoint(BaseAPIView): """This endpoint is used to get the signed URL for a static asset.""" @@ -755,6 +816,61 @@ class ProjectAssetEndpoint(BaseAPIView): # Redirect to the signed URL return HttpResponseRedirect(signed_url) +class ProjectReuploadAssetEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def post(self, request, slug, project_id, asset_id): + file_type = request.data.get("type", "image/jpeg") + file_size = request.data.get("size") + + if not file_size: + return Response( + {"error": "Missing required 'size' parameter"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + file_size = int(file_size) + except (ValueError, TypeError): + return Response( + {"error": "Invalid size parameter"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug, project_id=project_id) + except FileAsset.DoesNotExist: + return Response( + {"error": f"Asset with ID {asset_id} does not exist in this project"}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not asset.asset or not asset.asset.name: + return Response( + {"error": "Asset has no associated file"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + storage = S3Storage(request=request) + presigned_url = storage.generate_presigned_post( + object_name=asset.asset.name, + file_type=file_type, + file_size=min(file_size, settings.FILE_SIZE_LIMIT) + ) + + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + except Exception as e: + return Response( + {"error": f"Error generating upload URL: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) class ProjectBulkAssetEndpoint(BaseAPIView): def save_project_cover(self, asset, project_id): @@ -883,3 +999,65 @@ class ProjectAssetDownloadEndpoint(BaseAPIView): ) return HttpResponseRedirect(signed_url) + + + +class WorkspaceFileAssetServerEndpoint(BaseAPIView): + """ + This endpoint is used to upload cover images/logos + etc for workspace, projects and users. + """ + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def get(self, request, slug, asset_id): + # get the asset id + asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + # Get the presigned URL + storage = S3Storage(request=request, is_server=True) + # Generate a presigned URL to share an S3 object + signed_url = storage.generate_presigned_url( + object_name=asset.asset.name, + disposition="attachment", + filename=asset.attributes.get("name"), + ) + # Redirect to the signed URL + return HttpResponseRedirect(signed_url) + + +class ProjectAssetServerEndpoint(BaseAPIView): + """This endpoint is used to upload cover images/logos etc for workspace, projects and users.""" + + authentication_classes = [JWTAuthentication, BaseSessionAuthentication] + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, asset_id): + # get the asset id + asset = FileAsset.objects.get( + workspace__slug=slug, project_id=project_id, pk=asset_id + ) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get the presigned URL + storage = S3Storage(request=request, is_server=True) + # Generate a presigned URL to share an S3 object + signed_url = storage.generate_presigned_url( + object_name=asset.asset.name, + disposition="attachment", + filename=asset.attributes.get("name"), + ) + # Redirect to the signed URL + return HttpResponseRedirect(signed_url) + diff --git a/apps/api/plane/utils/content_validator.py b/apps/api/plane/utils/content_validator.py index 70f9d1131a..f9ffab0c09 100644 --- a/apps/api/plane/utils/content_validator.py +++ b/apps/api/plane/utils/content_validator.py @@ -138,6 +138,8 @@ EE_ATTRIBUTES = { # math components (generic) "inline-math-component": {"latex", "id"}, "block-math-component": {"latex", "id"}, + # drawio components (generic) + "drawio-component": {"id", "data-image-src", "data-xml-src", "data-mode"}, } # Merge nh3 defaults with all attributes used across our custom components diff --git a/apps/dev-wiki/app/(all)/[workspaceSlug]/(wiki)/wiki/[pageId]/page.tsx b/apps/dev-wiki/app/(all)/[workspaceSlug]/(wiki)/wiki/[pageId]/page.tsx index cef896601c..fb7fa072fc 100644 --- a/apps/dev-wiki/app/(all)/[workspaceSlug]/(wiki)/wiki/[pageId]/page.tsx +++ b/apps/dev-wiki/app/(all)/[workspaceSlug]/(wiki)/wiki/[pageId]/page.tsx @@ -78,7 +78,7 @@ const PageDetailsPage = observer(() => { }, fetchDescriptionBinary: async () => { if (!workspaceSlug || !id) return; - return await workspacePageService.fetchDescriptionBinary({ workspaceSlug: workspaceSlug.toString() }, id); + return await workspacePageService.fetchDescriptionBinary(workspaceSlug.toString(), id); }, fetchEntity: fetchEntityCallback, fetchVersionDetails: async (pageId, versionId) => { diff --git a/apps/dev-wiki/app/(all)/[workspaceSlug]/(wiki)/wiki/layout.tsx b/apps/dev-wiki/app/(all)/[workspaceSlug]/(wiki)/wiki/layout.tsx index 48033730a3..3c7bcba2df 100644 --- a/apps/dev-wiki/app/(all)/[workspaceSlug]/(wiki)/wiki/layout.tsx +++ b/apps/dev-wiki/app/(all)/[workspaceSlug]/(wiki)/wiki/layout.tsx @@ -8,43 +8,42 @@ import { EUserPermissions } from "@plane/constants"; import WorkspaceAccessWrapper from "@/layouts/access/workspace-wrapper"; // plane web components // import { PagesAppCommandPalette } from "@/plane-web/components/command-palette"; +// import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; import { WithFeatureFlagHOC } from "@/plane-web/components/feature-flags"; import { WorkspacePagesUpgrade } from "@/plane-web/components/pages"; // plane web layouts -import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; // local components // import { FloatingActionsRoot } from "../../(projects)/floating-action-bar"; import { PagesAppSidebar } from "./sidebar"; import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; +import { WorkspaceAuthWrapper } from "@/layouts/auth-layout"; export default function WorkspacePagesLayout({ children }: { children: React.ReactNode }) { // router const { workspaceSlug } = useParams(); + const workspaceSlugStr = Array.isArray(workspaceSlug) ? workspaceSlug[0] : workspaceSlug; return ( - } - > - - {/* */} -
- -
- {children} -
- {/* */} - {/* */} -
-
-
+ > */} + + {/* */} +
+ +
+ {children} +
+ {/* */} + {/* */} +
+
+ {/* */}
); diff --git a/apps/dev-wiki/core/components/editor/document/editor.tsx b/apps/dev-wiki/core/components/editor/document/editor.tsx index 95fd40d31e..ce906b7a15 100644 --- a/apps/dev-wiki/core/components/editor/document/editor.tsx +++ b/apps/dev-wiki/core/components/editor/document/editor.tsx @@ -6,6 +6,7 @@ import { type EditorRefApi, type IDocumentEditorProps, type TFileHandler, + TEmbedConfig, } from "@plane/editor"; import { MakeOptional, TSearchEntityRequestPayload, TSearchResponse } from "@plane/types"; import { cn } from "@plane/utils"; diff --git a/apps/dev-wiki/core/components/pages/editor/editor-body.tsx b/apps/dev-wiki/core/components/pages/editor/editor-body.tsx index af9384bac3..2a223d0455 100644 --- a/apps/dev-wiki/core/components/pages/editor/editor-body.tsx +++ b/apps/dev-wiki/core/components/pages/editor/editor-body.tsx @@ -277,6 +277,7 @@ export const PageEditorBody: React.FC = observer((props) => { id={pageId} fileHandler={config.fileHandler} handleEditorReady={handleEditorReady} + extendedEditorProps={extendedEditorProps} ref={editorForwardRef} titleRef={titleEditorRef} containerClassName="h-full p-0 pb-64" @@ -304,7 +305,6 @@ export const PageEditorBody: React.FC = observer((props) => { aiHandler={{ menu: getAIMenu, }} - extendedEditorProps={extendedEditorProps} /> diff --git a/apps/dev-wiki/core/hooks/editor/use-editor-config.ts b/apps/dev-wiki/core/hooks/editor/use-editor-config.ts index 00eff17527..552a0cd39d 100644 --- a/apps/dev-wiki/core/hooks/editor/use-editor-config.ts +++ b/apps/dev-wiki/core/hooks/editor/use-editor-config.ts @@ -1,16 +1,24 @@ import { useCallback } from "react"; // plane editor -import { TFileHandler, TReadOnlyFileHandler } from "@plane/editor"; +import type { TFileHandler } from "@plane/editor"; // helpers +import { TFileSignedURLResponse } from "@plane/types"; import { getEditorAssetSrc } from "@/helpers/editor.helper"; +import { getAssetIdFromUrl } from "@/helpers/file.helper"; // hooks import { useEditorAsset } from "@/hooks/store"; // plane web hooks import { useFileSize } from "@/plane-web/hooks/use-file-size"; // services +import { liveService } from "@/plane-web/services/live.service"; import { FileService } from "@/services/file.service"; const fileService = new FileService(); +// Extended file handler type that includes diagram content method +type TExtendedFileHandler = TFileHandler & { + getFileContent: (src: string) => Promise; +}; + type TArgs = { projectId?: string; uploadFile: TFileHandler["upload"]; @@ -25,7 +33,7 @@ export const useEditorConfig = () => { const { maxFileSize } = useFileSize(); const getReadOnlyEditorFileHandlers = useCallback( - (args: Pick): TReadOnlyFileHandler => { + (args: Pick) => { const { projectId, workspaceId, workspaceSlug } = args; return { @@ -33,7 +41,7 @@ export const useEditorConfig = () => { const res = await fileService.checkIfAssetExists(workspaceSlug, assetId); return res?.exists ?? false; }, - getAssetSrc: async (path) => { + getAssetSrc: async (path: string) => { if (!path) return ""; if (path?.startsWith("http")) { return path; @@ -47,6 +55,45 @@ export const useEditorConfig = () => { ); } }, + getAssetDownloadSrc: async (path: string) => { + if (!path) return ""; + if (path?.startsWith("http")) { + return path; + } else { + return ( + getEditorAssetSrc({ + assetId: path, + projectId, + workspaceSlug, + }) ?? "" + ); + } + }, + getFileContent: async (src: string): Promise => { + if (!src) return ""; + + try { + // Get the .drawio file URL + let fileUrl: string; + if (src.startsWith("http")) { + fileUrl = src; + } else { + fileUrl = + getEditorAssetSrc({ + assetId: src, + projectId, + workspaceSlug, + }) ?? ""; + } + + if (!fileUrl) return ""; + + return liveService.getContent(fileUrl); + } catch (error) { + console.error("Error loading diagram content:", error); + return ""; + } + }, restore: async (src: string) => { if (src?.startsWith("http")) { await fileService.restoreOldEditorAsset(workspaceId, src); @@ -60,7 +107,7 @@ export const useEditorConfig = () => { ); const getEditorFileHandlers = useCallback( - (args: TArgs): TFileHandler => { + (args: TArgs): TExtendedFileHandler => { const { projectId, uploadFile, workspaceId, workspaceSlug } = args; return { @@ -71,6 +118,18 @@ export const useEditorConfig = () => { }), assetsUploadStatus: assetsUploadPercentage, upload: uploadFile, + reupload: async (blockId: string, file: File, assetSrc: string): Promise => { + const assetId = getAssetIdFromUrl(assetSrc); + let response: TFileSignedURLResponse; + if (projectId) { + // Project-level asset reupload + response = await fileService.reuploadProjectAsset(workspaceSlug, projectId, assetId, file); + } else { + // Workspace-level asset reupload + response = await fileService.reuploadWorkspaceAsset(workspaceSlug, assetId, file); + } + return response.asset_id; + }, delete: async (src: string) => { if (src?.startsWith("http")) { await fileService.deleteOldWorkspaceAsset(workspaceId, src); @@ -88,7 +147,7 @@ export const useEditorConfig = () => { validation: { maxFileSize, }, - }; + } as TExtendedFileHandler; }, [assetsUploadPercentage, getReadOnlyEditorFileHandlers, maxFileSize] ); diff --git a/apps/dev-wiki/core/services/file.service.ts b/apps/dev-wiki/core/services/file.service.ts index 5cb5a57e99..6fed4a4f8f 100644 --- a/apps/dev-wiki/core/services/file.service.ts +++ b/apps/dev-wiki/core/services/file.service.ts @@ -314,6 +314,61 @@ export class FileService extends APIService { }); } + async reuploadWorkspaceAsset( + workspaceSlug: string, + assetId: string, + file: File, + uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"] + ): Promise { + const fileMetaData = getFileMetaDataForUpload(file); + return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/reupload/${assetId}/`, { + type: fileMetaData.type, + size: fileMetaData.size, + }) + .then(async (response) => { + const signedURLResponse: TFileSignedURLResponse = response?.data; + const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file); + await this.fileUploadService.uploadFile( + signedURLResponse.upload_data.url, + fileUploadPayload, + uploadProgressHandler + ); + await this.updateWorkspaceAssetUploadStatus(workspaceSlug, signedURLResponse.asset_id); + return signedURLResponse; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + + async reuploadProjectAsset( + workspaceSlug: string, + projectId: string, + assetId: string, + file: File, + uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"] + ): Promise { + const fileMetaData = getFileMetaDataForUpload(file); + return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/reupload/${assetId}/`, { + type: fileMetaData.type, + size: fileMetaData.size, + }) + .then(async (response) => { + const signedURLResponse: TFileSignedURLResponse = response?.data; + const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file); + await this.fileUploadService.uploadFile( + signedURLResponse.upload_data.url, + fileUploadPayload, + uploadProgressHandler + ); + await this.updateProjectAssetUploadStatus(workspaceSlug, projectId, signedURLResponse.asset_id); + return signedURLResponse; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + async getProjectCoverImages(): Promise { return this.get(`/api/project-covers/`) .then((res) => res?.data) diff --git a/apps/dev-wiki/ee/components/pages/editor/external-embed/embed-handler.tsx b/apps/dev-wiki/ee/components/pages/editor/external-embed/embed-handler.tsx index 7a59ce7b70..8037db9a8b 100644 --- a/apps/dev-wiki/ee/components/pages/editor/external-embed/embed-handler.tsx +++ b/apps/dev-wiki/ee/components/pages/editor/external-embed/embed-handler.tsx @@ -23,7 +23,7 @@ import { TwitterEmbed } from "@plane/ui/src/editor/twitter-embed"; // local hooks import { useUser } from "@/hooks/store/user"; // plane web services -import { iframelyService } from "@/plane-web/services/iframely.service"; +import { liveService } from "@/plane-web/services/live.service"; // Types type ErrorData = { @@ -54,7 +54,7 @@ const useEmbedDataManager = (externalEmbedNodeView: ExternalEmbedNodeViewProps) } = useSWR( swrKey, ([src, isThemeDark, workspaceSlug, userId]: [string, boolean, string, string]) => - iframelyService.getEmbedData(src, isThemeDark, workspaceSlug, userId), + liveService.getEmbedData(src, isThemeDark, workspaceSlug, userId), { revalidateOnFocus: false, revalidateOnReconnect: false, diff --git a/apps/dev-wiki/ee/components/pages/list/root.tsx b/apps/dev-wiki/ee/components/pages/list/root.tsx index 498a9de258..4f21f91b7a 100644 --- a/apps/dev-wiki/ee/components/pages/list/root.tsx +++ b/apps/dev-wiki/ee/components/pages/list/root.tsx @@ -181,11 +181,11 @@ export const WikiPagesListLayoutRoot: React.FC = observer((props) => { return (
- 0 ? resolvedNameFilterImage : resolvedFiltersImage} - className="h-36 sm:h-48 w-36 sm:w-48 mx-auto" - alt="No matching pages" - /> + {/* 0 ? NameFilterImage } */} + {/* className="h-36 sm:h-48 w-36 sm:w-48 mx-auto" */} + {/* alt="No matching pages" */} + {/* /> */}
No matching pages

{filters.searchQuery.length > 0 diff --git a/apps/dev-wiki/ee/hooks/pages/use-extended-editor-extensions.ts b/apps/dev-wiki/ee/hooks/pages/use-extended-editor-extensions.ts index 2616d766bd..0040a4a0c0 100644 --- a/apps/dev-wiki/ee/hooks/pages/use-extended-editor-extensions.ts +++ b/apps/dev-wiki/ee/hooks/pages/use-extended-editor-extensions.ts @@ -4,6 +4,7 @@ import type { IEditorPropsExtended, TCommentConfig } from "@plane/editor"; // hooks import type { TSearchEntityRequestPayload, TSearchResponse } from "@plane/types"; // plane web hooks +import { LogoSpinner } from "@/components/common"; import { useUserProfile } from "@/hooks/store"; import { useEditorEmbeds } from "@/plane-web/hooks/use-editor-embed"; import { type TPageInstance } from "@/store/pages/base-page"; @@ -11,7 +12,7 @@ import { EPageStoreType } from "../store"; export type TExtendedEditorExtensionsConfig = Pick< IEditorPropsExtended, - "embedHandler" | "commentConfig" | "isSmoothCursorEnabled" + "embedHandler" | "commentConfig" | "isSmoothCursorEnabled" | "logoSpinner" >; export type TExtendedEditorExtensionsHookParams = { @@ -49,6 +50,7 @@ export const useExtendedEditorProps = ( embedHandler: embedProps, commentConfig: extensionHandlers?.get("comments") as TCommentConfig | undefined, isSmoothCursorEnabled: is_smooth_cursor_enabled, + logoSpinner: LogoSpinner, }), [embedProps, extensionHandlers, is_smooth_cursor_enabled] ); diff --git a/apps/dev-wiki/ee/hooks/use-editor-flagging.ts b/apps/dev-wiki/ee/hooks/use-editor-flagging.ts index e74f332eb3..8401123626 100644 --- a/apps/dev-wiki/ee/hooks/use-editor-flagging.ts +++ b/apps/dev-wiki/ee/hooks/use-editor-flagging.ts @@ -41,15 +41,15 @@ export const useEditorFlagging = (props: TEditorFlaggingHookProps): TEditorFlagg documentDisabled.push("ai"); } if (!isCollaborationCursorEnabled) { - documentDisabled.push("collaboration-cursor"); + // documentDisabled.push("collaboration-cursor"); } if (storeType && !isNestedPagesEnabled(workspaceSlug)) { documentFlagged.push("nested-pages"); } - if (!isEditorAttachmentsEnabled) { - documentFlagged.push("attachments"); - richTextFlagged.push("attachments"); - } + // if (!isEditorAttachmentsEnabled) { + documentFlagged.push("attachments"); + richTextFlagged.push("attachments"); + // } if (!isEditorMathematicsEnabled) { documentFlagged.push("mathematics"); richTextFlagged.push("mathematics"); diff --git a/apps/dev-wiki/ee/services/iframely.service.ts b/apps/dev-wiki/ee/services/live.service.ts similarity index 75% rename from apps/dev-wiki/ee/services/iframely.service.ts rename to apps/dev-wiki/ee/services/live.service.ts index 697954e59f..fb55aab2cd 100644 --- a/apps/dev-wiki/ee/services/iframely.service.ts +++ b/apps/dev-wiki/ee/services/live.service.ts @@ -3,7 +3,7 @@ import { LIVE_BASE_URL } from "@plane/constants"; import { IframelyResponse } from "@plane/types"; import { APIService } from "@/services/api.service"; -export class IframelyService extends APIService { +export class LiveService extends APIService { constructor() { super(LIVE_BASE_URL); } @@ -33,7 +33,14 @@ export class IframelyService extends APIService { ); return response.data; } + + async getContent(url: string): Promise { + const response = await this.get(`/content`, { + params: { url: url }, + }); + return response.data.content; + } } // Create a singleton instance -export const iframelyService = new IframelyService(); +export const liveService = new LiveService(); diff --git a/apps/dev-wiki/tailwind.config.js b/apps/dev-wiki/tailwind.config.js index b6377a0451..34afcdf18f 100644 --- a/apps/dev-wiki/tailwind.config.js +++ b/apps/dev-wiki/tailwind.config.js @@ -4,4 +4,21 @@ const sharedConfig = require("@plane/tailwind-config/tailwind.config.js"); module.exports = { presets: [sharedConfig], + content: { + relative: true, + files: [ + "./app/**/*.{js,ts,jsx,tsx}", + "./core/**/*.{js,ts,jsx,tsx}", + "./ce/**/*.{js,ts,jsx,tsx}", + "./ee/**/*.{js,ts,jsx,tsx}", + "./components/**/*.tsx", + "./constants/**/*.{js,ts,jsx,tsx}", + "./layouts/**/*.tsx", + "./helpers/**/*.{js,ts,jsx,tsx}", + "../../packages/ui/src/**/*.{js,ts,jsx,tsx}", + "../../packages/propel/src/**/*.{js,ts,jsx,tsx}", + "../../packages/editor/src/**/*.{js,ts,jsx,tsx}", + "!../../packages/ui/**/*.stories{js,ts,jsx,tsx}", + ], + }, }; diff --git a/apps/live/src/ee/controllers/content.controller.ts b/apps/live/src/ee/controllers/content.controller.ts new file mode 100644 index 0000000000..3ac3326e98 --- /dev/null +++ b/apps/live/src/ee/controllers/content.controller.ts @@ -0,0 +1,59 @@ +import type { Request, Response } from "express"; +import { Controller, Get } from "@plane/decorators"; +// services +import { ContentAPI } from "@/ee/services/content.service"; + +// Error interface for better type safety +type APIError = { + status?: number; + message?: string; + response?: { + data?: unknown; + }; +}; + +@Controller("/content") +export class ContentController { + @Get("/") + async getFileContent(req: Request, res: Response) { + try { + // Extract URL from query params + const { url } = req.query; + if (!url || typeof url !== "string") { + return res.status(400).json({ + error: "URL parameter is required", + message: "Please provide a valid URL in the query parameters", + }); + } + + // Extract cookie from request headers + const cookie = req.headers.cookie; + + // Use the ContentService to fetch data + const response = await ContentAPI.getFileContent({ + url: url, + cookie: cookie, + }); + + return res.json({ + success: true, + content: response, + }); + } catch (error: unknown) { + // Handle different types of errors + const apiError = error as APIError; + if (apiError?.status) { + return res.status(apiError.status).json({ + error: "Failed to fetch content", + message: apiError.message || "External service error", + details: apiError, + }); + } + + return res.status(500).json({ + error: "Internal server error", + message: "Failed to fetch content from the provided URL", + }); + } + } +} diff --git a/apps/live/src/ee/controllers/index.ts b/apps/live/src/ee/controllers/index.ts index 400cdb90af..5da97fa227 100644 --- a/apps/live/src/ee/controllers/index.ts +++ b/apps/live/src/ee/controllers/index.ts @@ -1,10 +1,11 @@ import { CONTROLLERS as CEControllers } from "@/ce/controllers"; import { BroadcastController } from "./broadcast.controller"; +import { ContentController } from "./content.controller"; import { IframelyController } from "./iframely.controller"; export const CONTROLLERS = { // Core system controllers (health checks, status endpoints) - CORE: [...CEControllers.CORE, IframelyController], + CORE: [...CEControllers.CORE, IframelyController, ContentController], // Document management controllers DOCUMENT: [...CEControllers.DOCUMENT], diff --git a/apps/live/src/ee/services/content.service.ts b/apps/live/src/ee/services/content.service.ts new file mode 100644 index 0000000000..4a2d7316e5 --- /dev/null +++ b/apps/live/src/ee/services/content.service.ts @@ -0,0 +1,56 @@ +// services +import { API_BASE_URL, APIService } from "@/core/services/api.service"; +import { env } from "@/env"; + +// Base params interface for content operations +type ContentParams = { + url: string; + cookie?: string; +}; + +export class ContentService extends APIService { + constructor(baseURL: string = API_BASE_URL) { + super(baseURL); + } + + /** + * Gets the common headers used for requests, similar to BasePageService + */ + protected getHeaders(params: { cookie?: string }): Record { + const { cookie } = params; + const headers: Record = {}; + const liveServerSecretKey = env.LIVE_SERVER_SECRET_KEY; + + if (cookie) { + headers.Cookie = cookie; + } + + if (liveServerSecretKey) { + headers["live-server-secret-key"] = liveServerSecretKey; + } + + return headers; + } + + /** + * Fetches content from a given URL with proper cookie handling + */ + async getFileContent(params: ContentParams) { + const { url, cookie } = params; + + // We have to add the server/ to the url because the asset endpoint server expects it + // by removing this, the request will fail since server will server asset assuming it is a browser request and it expect some redirect to the asset url + const serverAssetUrl = url + (url.endsWith("/") ? "server/" : "/server/"); + return this.get(serverAssetUrl, { + headers: this.getHeaders({ cookie }), + withCredentials: true, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data || error; + }); + } +} + +// Create a singleton instance +export const ContentAPI = new ContentService(); diff --git a/apps/silo/src/apps/engine/controllers/app.controller.ts b/apps/silo/src/apps/engine/controllers/app.controller.ts index 2e3bc7af04..2fc436df3e 100644 --- a/apps/silo/src/apps/engine/controllers/app.controller.ts +++ b/apps/silo/src/apps/engine/controllers/app.controller.ts @@ -1,10 +1,17 @@ import { Request, Response } from "express"; import { Controller, Get } from "@plane/decorators"; -import { E_IMPORTER_KEYS, TAppKeys } from "@plane/etl/core"; +import { E_IMPORTER_KEYS, E_SILO_ERROR_CODES, TAppKeys } from "@plane/etl/core"; +import { logger } from "@plane/logger"; import { E_INTEGRATION_KEYS } from "@plane/types"; +import { env } from "@/env"; import { getSupportedIntegrations } from "@/helpers/app"; +import { integrationConnectionHelper } from "@/helpers/integration-connection-helper"; import { getPlaneAppDetails } from "@/helpers/plane-app-details"; import { responseHandler } from "@/helpers/response-handler"; +import { useValidateUserAuthentication } from "@/lib"; +import { planeOAuthService } from "@/services/oauth"; +import { EOAuthGrantType } from "@/types/oauth"; +import { getAppOAuthCallbackUrl, getCallbackSuccessUrl } from "../helpers/urls"; @Controller("/api") export class AppController { @@ -24,7 +31,7 @@ export class AppController { const { provider } = req.params; if ( !Object.values([E_IMPORTER_KEYS.IMPORTER, ...Object.values(E_INTEGRATION_KEYS)].flat()).includes( - provider as TAppKeys + provider as E_INTEGRATION_KEYS | E_IMPORTER_KEYS ) ) { throw new Error("Invalid provider"); @@ -39,4 +46,151 @@ export class AppController { responseHandler(res, 500, error); } } + + /** + * Get OAuth app installation status for workspace + */ + @Get("/apps/:workspaceId/enabled-integrations") + @useValidateUserAuthentication() + async getEnabledIntegrationsForWorkspace(req: Request, res: Response): Promise { + const { workspaceId } = req.params; + try { + const allConnections = await integrationConnectionHelper.getWorkspaceConnections({ + workspace_id: workspaceId as string, + }); + + // Extract unique connection types + const seenTypes = new Set(); + const connectionTypes: { + id: string; + connection_provider: string; + connection_slug: string | null | undefined; + connection_id: string; + }[] = allConnections + .filter((connection) => { + if (seenTypes.has(connection.connection_type)) { + return false; + } + seenTypes.add(connection.connection_type); + return true; + }) + .map((connection) => ({ + id: connection.connection_type, + connection_provider: connection.connection_type, + connection_slug: connection.connection_slug, + connection_id: connection.connection_id, + })); + res.status(200).json(connectionTypes); + } catch (error: any) { + responseHandler(res, 500, error); + } + } + + /** + * Generate consent URL for OAuth app installation + */ + @Get("/apps/:provider/auth/consent-url") + @useValidateUserAuthentication() + async createConsentUrl(req: Request, res: Response): Promise { + const { provider } = req.params; + + if (!provider) { + res.status(400).json({ + error: "Missing required fields", + message: "provider is required", + }); + return; + } + + try { + const { planeAppClientId } = await getPlaneAppDetails(provider as TAppKeys); + if (!planeAppClientId) { + res.status(500).json({ error: E_SILO_ERROR_CODES.INVALID_APP_CREDENTIALS }); + return; + } + + const redirectUri = getAppOAuthCallbackUrl(provider); + const consentUrl = planeOAuthService.getPlaneOAuthRedirectUrl(planeAppClientId, redirectUri, ""); + return res.status(302).redirect(consentUrl); + } catch (error: any) { + responseHandler(res, 500, error); + } + } + + /** + * Handle OAuth app callback and create workspace credentials/connections + */ + @Get("/apps/:provider/auth/callback") + async handleCallback(req: Request, res: Response): Promise { + const { provider } = req.params; + const { code, app_installation_id } = req.query; + + if (!provider || !code || !app_installation_id) { + res.status(400).json({ + error: "Missing required parameters", + message: "provider, code, and app_installation_id are required", + }); + return; + } + + try { + // Get Plane app details and generate token + const { planeAppClientId, planeAppClientSecret } = await getPlaneAppDetails(provider as TAppKeys); + if (!planeAppClientId || !planeAppClientSecret) { + const redirectBase = `${env.APP_BASE_URL}/error?error=${E_SILO_ERROR_CODES.INVALID_APP_CREDENTIALS}`; + res.redirect(redirectBase); + return; + } + + const tokenResponse = await planeOAuthService.generateToken({ + client_id: planeAppClientId, + client_secret: planeAppClientSecret, + grant_type: EOAuthGrantType.CLIENT_CREDENTIALS, + app_installation_id: app_installation_id as string, + }); + + // Get app installation details + const appInstallation = await planeOAuthService.getAppInstallation( + tokenResponse.access_token, + app_installation_id as string + ); + + // Create workspace credential + const credential = await integrationConnectionHelper.createOrUpdateWorkspaceCredential({ + workspace_id: appInstallation.workspace_detail.id, + user_id: appInstallation.installed_by, + source: provider as E_INTEGRATION_KEYS, + source_access_token: "", // OAuth apps don't typically have source tokens + source_refresh_token: "", + target_access_token: tokenResponse.access_token, + target_refresh_token: tokenResponse.refresh_token || "", + target_authorization_type: EOAuthGrantType.CLIENT_CREDENTIALS, + target_identifier: app_installation_id as string, + source_hostname: "", + source_identifier: appInstallation.id, + source_authorization_type: EOAuthGrantType.CLIENT_CREDENTIALS, + }); + + // Create workspace connection + await integrationConnectionHelper.createOrUpdateWorkspaceConnection({ + workspace_id: appInstallation.workspace_detail.id, + connection_type: provider as E_INTEGRATION_KEYS, + target_hostname: env.API_BASE_URL, + connection_id: appInstallation.id, + connection_data: { + app_installation: appInstallation, + }, + credential_id: credential.id, + connection_slug: `${provider}-${appInstallation.id}`, + config: {}, + }); + + // Redirect to success page + const redirectUri = getCallbackSuccessUrl(provider, appInstallation.workspace_detail.slug); + res.redirect(redirectUri); + } catch (error: any) { + logger.error("OAuth app callback error:", error); + res.redirect(`${env.APP_BASE_URL}/error?error=${E_SILO_ERROR_CODES.GENERIC_ERROR}`); + } + } } diff --git a/apps/silo/src/apps/engine/helpers/urls.ts b/apps/silo/src/apps/engine/helpers/urls.ts new file mode 100644 index 0000000000..9ec9b4fd24 --- /dev/null +++ b/apps/silo/src/apps/engine/helpers/urls.ts @@ -0,0 +1,9 @@ +import { env } from "@/env"; + +export const getAppOAuthCallbackUrl = (provider: string) => + `${env.SILO_API_BASE_URL}${env.SILO_BASE_PATH}/api/apps/${provider}/auth/callback`; + +export const getCallbackSuccessUrl = (provider: string, workspaceSlug?: string) => + workspaceSlug + ? `${env.APP_BASE_URL}/${workspaceSlug}/settings/integrations/${provider}/` + : `${env.APP_BASE_URL}/integrations/${provider}/success`; diff --git a/apps/silo/src/apps/notion-importer/drivers/confluence/content-parser/extensions/extract-attachments-config.ts b/apps/silo/src/apps/notion-importer/drivers/confluence/content-parser/extensions/extract-attachments-config.ts new file mode 100644 index 0000000000..fd02696ec4 --- /dev/null +++ b/apps/silo/src/apps/notion-importer/drivers/confluence/content-parser/extensions/extract-attachments-config.ts @@ -0,0 +1,50 @@ +import { HTMLElement } from "node-html-parser"; +import { TConfluenceContentParserContext } from "@/apps/notion-importer/types"; +import { CONFLUENCE_ATTACHMENT_SOURCE_SELECTOR } from "@/apps/notion-importer/utils/html-helpers"; +import { ExtractBodyExtension } from "../../../common/content-parser"; + +export type TConfluenceAttachmentConfig = { + fileName: string | undefined; + href: string | undefined; +}; + +/* + * This extension is used to extract out the attachment details at the bottom of the html + * page that is passed. Essentially this extension is created in order to support drawio and + * other embed imports, because fileName is the thing that connects both the href of the real + * file and the block of the emebd. + */ +export class ConfluenceExtractAttachmentConfigExtension extends ExtractBodyExtension { + sourceSelector = CONFLUENCE_ATTACHMENT_SOURCE_SELECTOR; + + /** + * Extracts the attachment config from the attachment node + * @param node + * @returns The attachment config + */ + async mutate(node: HTMLElement): Promise { + const attachments = node.querySelectorAll(this.config.selector); + + const attachmentConfig = attachments[0].childNodes + .map((attachment) => this.extractAttachmentConfig(attachment as HTMLElement)) + .filter((attachment) => attachment !== undefined); + + this.config.context?.set(TConfluenceContentParserContext.ATTACHMENTS, JSON.stringify(attachmentConfig)); + return node; + } + + /** + * Extracts the attachment config from the attachment node + * @param attachment + * @returns The attachment config + */ + extractAttachmentConfig(attachment: HTMLElement): TConfluenceAttachmentConfig | undefined { + if (attachment.tagName === "A" || attachment.rawTagName === "a") { + const attachmentConfig: TConfluenceAttachmentConfig = { + fileName: attachment.innerText, + href: attachment.getAttribute("href"), + }; + return attachmentConfig; + } + } +} diff --git a/apps/silo/src/apps/notion-importer/drivers/confluence/content-parser/extensions/extract-drawio-embed.ts b/apps/silo/src/apps/notion-importer/drivers/confluence/content-parser/extensions/extract-drawio-embed.ts new file mode 100644 index 0000000000..f25c5ead5b --- /dev/null +++ b/apps/silo/src/apps/notion-importer/drivers/confluence/content-parser/extensions/extract-drawio-embed.ts @@ -0,0 +1,240 @@ +import { HTMLElement } from "node-html-parser"; +import { v4 as uuidv4 } from "uuid"; +import { IParserExtension } from "@plane/etl/parser"; +import { logger } from "@plane/logger"; +import { TAssetInfo, TDocContentParserConfig } from "@/apps/notion-importer/types"; +import { + CONFLUENCE_DRAWIO_CONTAINER_CLASS, + CONFLUENCE_DRAWIO_CONTAINER_ID_PREFIXES, + CONFLUENCE_DRAWIO_SCRIPT_SELECTOR, +} from "@/apps/notion-importer/utils/html-helpers"; +import { TConfluenceAttachmentConfig } from "./extract-attachments-config"; + +enum EDrawioMode { + DIAGRAM = "diagram", + BOARD = "board", +} + +/** + * @overview This extension is used to extract the drawio embed from the confluence page + * Essentially the embed is encapsulated in a container with the specific class of ap-container, + * There are two child nodes, that need to be taken care of, + * 1. ap-content, which keeps the id of the iframe + * 2. script tag, which includes the script that can provide us with metadata + * for the file that we want to reference in the tag. + */ +export class ConfluenceExtractDrawioEmbedExtension implements IParserExtension { + drawioContainerClass = CONFLUENCE_DRAWIO_CONTAINER_CLASS; + drawioScriptSelector = CONFLUENCE_DRAWIO_SCRIPT_SELECTOR; + drawioContainerIdPrefixes = CONFLUENCE_DRAWIO_CONTAINER_ID_PREFIXES; + + constructor(private config: TDocContentParserConfig) { + this.config = config; + } + + /** + * Checks if the node is a drawio embed container + * @param node - The node to check + * @returns True if the node is a drawio embed container, false otherwise + */ + shouldParse(node: HTMLElement): boolean { + const isAPContainer = node.getAttribute("class")?.includes(this.drawioContainerClass) ?? false; + const isDrawioContainer = this.drawioContainerIdPrefixes.some( + (prefix) => node.getAttribute("id")?.includes(prefix) ?? false + ); + return isAPContainer && isDrawioContainer; + } + + /** + * Extracts the drawio embed from the node + * @param node - The node to extract the drawio embed from + * @returns The drawio embed component + */ + async mutate(node: HTMLElement): Promise { + try { + const scriptContent = this.extractScriptContent(node); + + // Get the filename from the script content + const fileName = this.extractFileNameFromScriptContent(scriptContent); + const attachmentMap = this.extractAttachmentMapFromContext(); + + // Get the associated details for the filename + const mode = this.extractFileModeFromNode(node); + const xmlAssetInfo = this.extractXMLAssetInfo(fileName, attachmentMap); + const imgAssetInfo = this.extractImgAssetInfo(fileName, attachmentMap); + + // Create the drawio component + return this.createDrawIoComponent(xmlAssetInfo.id, imgAssetInfo.id, mode); + } catch (error) { + logger.error(`Error extracting drawio embed from node:`, { error }); + return node; + } + } + + /** + * Extracts the script content from the node + * @param node - The node to extract the script content from + * @throws An error if the script tag is not found + * @returns The script content + */ + private extractScriptContent(node: HTMLElement): string { + const scriptContent = node.querySelector(this.drawioScriptSelector)?.innerHTML; + if (!scriptContent) { + throw new Error("Script tag not found"); + } + return scriptContent; + } + + /** + * Extracts the file name from the script content + * @param scriptContent - The script content + * @throws An error if the productCtx line is not found + * @throws An error if the diagram display name is not found + * @returns The file name + */ + private extractFileNameFromScriptContent(scriptContent: string): string { + const productCtxLine = this.extractProductCtxLineFromScriptContent(scriptContent); + + if (!productCtxLine) { + throw new Error("productCtx line not found"); + } + + const diagramDisplayName = this.extractDiagramDisplayNameFromProductCtxLine(productCtxLine); + if (!diagramDisplayName) { + throw new Error("Diagram display name not found"); + } + return diagramDisplayName; + } + + /** + * Extracts the attachment map from the context + * @returns The attachment map + */ + private extractAttachmentMapFromContext(): TConfluenceAttachmentConfig[] { + const attachmentMap = this.config.context?.get("attachments"); + return JSON.parse(attachmentMap || "[]") as TConfluenceAttachmentConfig[]; + } + + /** + * Extracts the asset info from the filename + * @param fileName - The filename + * @returns The asset info + */ + private extractFileModeFromNode(node: HTMLElement): EDrawioMode { + const id = node.getAttribute("id"); + if (id?.includes("sketch")) { + return EDrawioMode.BOARD; + } + + return EDrawioMode.DIAGRAM; + } + + /** + * Gets the XML asset info from the filename + * @param fileName - The filename + * @returns The XML asset info + */ + private extractXMLAssetInfo(fileName: string, attachmentMap: TConfluenceAttachmentConfig[]) { + const fileReference = attachmentMap?.find( + (attachment: TConfluenceAttachmentConfig) => attachment.fileName === fileName + ); + if (!fileReference) { + throw new Error(`File reference not found for file name: ${fileName}`); + } + return this.extractAssetInfoFromFilename(fileReference); + } + + /** + * Gets the image asset info from the filename + * @param fileName - The filename + * @returns The image asset info + */ + private extractImgAssetInfo(fileName: string, attachmentMap: TConfluenceAttachmentConfig[]) { + const fileReference = attachmentMap?.find( + (attachment: TConfluenceAttachmentConfig) => attachment.fileName === `${fileName}.png` + ); + if (!fileReference) { + throw new Error(`File reference not found for file name: ${fileName}`); + } + return this.extractAssetInfoFromFilename(fileReference); + } + + /** + * Gets the asset info from the filename + * @param fileReference - The file reference + * @returns The asset info + */ + private extractAssetInfoFromFilename(fileReference: TConfluenceAttachmentConfig) { + const normalizedFilePath = this.normalizeFilePath(fileReference?.href || ""); + const assetInfo = JSON.parse(this.config.assetMap.get(normalizedFilePath) || "{}") as TAssetInfo; + return assetInfo; + } + + /** + * Creates the drawio component + * @param xmlSrc - The XML source + * @param imgSrc - The image source + * @returns The drawio component + */ + private createDrawIoComponent(xmlSrc: string, imgSrc: string, mode = EDrawioMode.DIAGRAM) { + const uuid = uuidv4(); + + const component = new HTMLElement("drawio-component", {}, ""); + component.setAttribute("id", uuid); + component.setAttribute("data-xml-src", xmlSrc); + component.setAttribute("data-image-src", imgSrc); + component.setAttribute("data-mode", mode); + return component; + } + + /** + * Extracts the productCtx line from the script content + * @param scriptContent - The script content + * @returns The productCtx line + */ + private extractProductCtxLineFromScriptContent(scriptContent: string): string | undefined { + const lines = scriptContent.split("\n"); + const productCtxLine = lines.find((line: string) => line.includes('"productCtx":')); + return productCtxLine; + } + + /** + * Extracts the diagram display name from the productCtx line + * @param productCtxLine - The productCtx line + * @throws An error if the productCtx value is not found + * @returns The diagram display name + */ + private extractDiagramDisplayNameFromProductCtxLine(productCtxLine: string): string | undefined { + const match = productCtxLine.match(/"productCtx"\s*:\s*"(.+?)"\s*,?\s*$/); + if (!match) { + throw new Error("Could not extract productCtx value"); + } + const jsonString = match[1]; + const cleanedJsonString = jsonString.replaceAll("\\", ""); + const json = JSON.parse(cleanedJsonString) as { diagramDisplayName?: string }; + // Either it will return name or undefined + return json.diagramDisplayName; + } + + /** + * Normalizes the file path + * @param src - The source + * @returns The normalized file path + */ + protected normalizeFilePath(src: string): string { + // Remove URL encoding and construct the full path + // This should match how paths were stored in phase one + const decodedSrc = decodeURIComponent(src); + // Remove all the query params and everything after it + const withoutQueryParams = decodedSrc.split("?")[0]; + + const components = withoutQueryParams.split("/"); + if (components.length > 2) { + // Split the path by / and take the last two components + const lastTwoComponents = withoutQueryParams.split("/").slice(-2); + return lastTwoComponents.join("/"); + } + + return withoutQueryParams; + } +} diff --git a/apps/silo/src/apps/notion-importer/drivers/confluence/content-parser/extensions/process-files.ts b/apps/silo/src/apps/notion-importer/drivers/confluence/content-parser/extensions/process-files.ts index 2b55e7d4a8..b631a49225 100644 --- a/apps/silo/src/apps/notion-importer/drivers/confluence/content-parser/extensions/process-files.ts +++ b/apps/silo/src/apps/notion-importer/drivers/confluence/content-parser/extensions/process-files.ts @@ -10,7 +10,8 @@ export class ConfluenceFileParserExtension extends NotionFileParserExtension { const hasAnchorTag = node.tagName === "A"; const isNotPageLink = !node.getAttribute("href")?.includes("/pages/"); const isNotMention = !node.getAttribute("href")?.includes("/people/"); - return hasAnchorTag && isNotPageLink && isNotMention; + const isNonHTMLfile = this.isNonHTMLFile(node.getAttribute("href") || ""); + return hasAnchorTag && isNotPageLink && isNotMention && isNonHTMLfile; } protected getFileSource(node: HTMLElement): string { diff --git a/apps/silo/src/apps/notion-importer/drivers/confluence/driver.ts b/apps/silo/src/apps/notion-importer/drivers/confluence/driver.ts index d3a94d6517..55b35f872d 100644 --- a/apps/silo/src/apps/notion-importer/drivers/confluence/driver.ts +++ b/apps/silo/src/apps/notion-importer/drivers/confluence/driver.ts @@ -5,6 +5,7 @@ import { logger } from "@plane/logger"; import { TZipFileNode, ZipManager } from "@/lib/zip-manager"; import { EZipNodeType } from "@/lib/zip-manager/types"; import { TDocContentParserConfig } from "../../types"; +import { CONFLUENCE_ATTACHMENT_CONTAINER_SELECTOR, CONFLUENCE_BODY_SELECTOR } from "../../utils/html-helpers"; import { NotionImageParserExtension } from "../common/content-parser"; import { NotionBlockColorParserExtension, @@ -24,6 +25,8 @@ import { ConfluenceTaskListParserExtension, PTagCustomComponentExtension, } from "./content-parser"; +import { ConfluenceExtractAttachmentConfigExtension } from "./content-parser/extensions/extract-attachments-config"; +import { ConfluenceExtractDrawioEmbedExtension } from "./content-parser/extensions/extract-drawio-embed"; /* * Confluence zip manager is a custom implementation on top of the existing zip @@ -59,7 +62,8 @@ export class ConfluenceImportDriver implements IZipImportDriver { ); const preprocessExtensions: IParserExtension[] = [ - new ConfluenceExtractBodyExtension({ selector: "div#main-content", context }), + new ConfluenceExtractAttachmentConfigExtension({ selector: CONFLUENCE_ATTACHMENT_CONTAINER_SELECTOR, context }), + new ConfluenceExtractBodyExtension({ selector: CONFLUENCE_BODY_SELECTOR, context }), new ConfluenceTaskListParserExtension(), new ConfluenceIconParserExtension(), new ConfluencePageParserExtension(config), @@ -68,6 +72,10 @@ export class ConfluenceImportDriver implements IZipImportDriver { /*----------- Core Extensions -----------*/ const coreExtensions: IParserExtension[] = [ new ProcessLinksExtension(), + new ConfluenceExtractDrawioEmbedExtension({ + ...config, + context, + }), new ConfluenceStatusMacroParserExtension(), new ConfluenceColorIdParserExtension(context), new ConfluenceBackgroundColorParserExtension(), @@ -105,7 +113,8 @@ export class ConfluenceImportDriver implements IZipImportDriver { if (!fileTree) { return undefined; } - return fileTree; + const mergedFileTree = this.mergeAttachmentsFromToc(fileTree, toc); + return mergedFileTree; } /* @@ -122,6 +131,86 @@ export class ConfluenceImportDriver implements IZipImportDriver { return content.toString(); } + /** + * Merges attachment files from the table of contents into the file tree + * @param root - The root file tree node + * @param toc - Table of contents containing all file paths + * @returns The updated file tree with merged attachments + */ + private mergeAttachmentsFromToc(root: TZipFileNode, toc: string[]): TZipFileNode { + const attachmentPaths = toc.filter((file) => file.includes("attachments/") && !file.endsWith("/")); + + // Group attachments by their reference ID (the number after attachments/) + const attachmentsByRef = new Map(); + + attachmentPaths.forEach((path) => { + const match = path.match(/attachments\/(\d+)\//); + if (match) { + const refId = match[1]; + if (!attachmentsByRef.has(refId)) { + attachmentsByRef.set(refId, []); + } + attachmentsByRef.get(refId)!.push(path); + } + }); + + // Recursively traverse the tree and add missing attachments + this.addAttachmentsToNode(root, attachmentsByRef); + + return root; + } + + /** + * Recursively adds missing attachment nodes to directories that correspond to their reference IDs + * @param node - Current node being processed + * @param attachmentsByRef - Map of reference IDs to their attachment file paths + */ + private addAttachmentsToNode(node: TZipFileNode, attachmentsByRef: Map): void { + if (!node.children) return; + + // Process each child node + for (const child of node.children) { + // If this is a directory, check if it corresponds to a file with attachments + if (child.type === EZipNodeType.DIRECTORY) { + // Extract reference ID from the directory path + const pathParts = child.path.split("_"); + const refId = pathParts[pathParts.length - 1]; + + if (refId && attachmentsByRef.has(refId)) { + const attachmentPaths = attachmentsByRef.get(refId)!; + + // Get existing attachment paths in this directory + const existingAttachmentPaths = new Set( + child.children + ?.filter((childNode) => childNode.path.includes("attachments/")) + .map((childNode) => childNode.path) || [] + ); + + // Add missing attachments + attachmentPaths.forEach((attachmentPath) => { + if (!existingAttachmentPaths.has(attachmentPath)) { + const attachmentNode: TZipFileNode = { + id: crypto.randomUUID(), + name: attachmentPath.split("/").pop() || attachmentPath, + type: EZipNodeType.FILE, + path: attachmentPath, + children: [], + }; + + if (!child.children) { + child.children = []; + } + child.children.push(attachmentNode); + } + }); + } + + // Recursively process child directories + this.addAttachmentsToNode(child, attachmentsByRef); + } + } + } + /* * Parses the index.html file content and builds the file tree * @param indexFileContent - The content of the index.html file diff --git a/apps/silo/src/apps/notion-importer/types/index.ts b/apps/silo/src/apps/notion-importer/types/index.ts index e0c14f8355..2422a45313 100644 --- a/apps/silo/src/apps/notion-importer/types/index.ts +++ b/apps/silo/src/apps/notion-importer/types/index.ts @@ -50,6 +50,10 @@ export type TAssetInfo = { size: number; }; +export enum TConfluenceContentParserContext { + ATTACHMENTS = "attachments", +} + export type TCalloutConfig = { icon: string; color: string; diff --git a/apps/silo/src/apps/notion-importer/utils/html-helpers.ts b/apps/silo/src/apps/notion-importer/utils/html-helpers.ts index 557079b235..e301384d4b 100644 --- a/apps/silo/src/apps/notion-importer/utils/html-helpers.ts +++ b/apps/silo/src/apps/notion-importer/utils/html-helpers.ts @@ -38,3 +38,14 @@ export const getEmojiPayload = (htmlContent: string) => { in_use: "emoji", }; }; + +export const CONFLUENCE_ATTACHMENT_CONTAINER_SELECTOR = "div.greybox"; +export const CONFLUENCE_ATTACHMENT_SOURCE_SELECTOR = "a"; +export const CONFLUENCE_BODY_SELECTOR = "div#main-content"; + +export const CONFLUENCE_DRAWIO_CONTAINER_CLASS = "ap-container"; +export const CONFLUENCE_DRAWIO_CONTAINER_ID_PREFIXES = [ + "mxgraph.confluence.plugins.diagramly__inc-drawio", + "mxgraph.confluence.plugins.diagramly__drawio", +]; +export const CONFLUENCE_DRAWIO_SCRIPT_SELECTOR = "script.ap-iframe-body-script"; diff --git a/apps/silo/src/helpers/app.ts b/apps/silo/src/helpers/app.ts index 7be8a4b7ed..4d664bf813 100644 --- a/apps/silo/src/helpers/app.ts +++ b/apps/silo/src/helpers/app.ts @@ -29,6 +29,7 @@ export const getSupportedIntegrations = () => isSlackEnabled() && E_INTEGRATION_KEYS.SLACK, isGitlabEnabled() && E_INTEGRATION_KEYS.GITLAB, isSentryEnabled() && E_INTEGRATION_KEYS.SENTRY, + E_INTEGRATION_KEYS.DRAWIO_INTEGRATION, E_INTEGRATION_KEYS.GITHUB_ENTERPRISE, E_INTEGRATION_KEYS.GITLAB_ENTERPRISE, ].filter(Boolean) as E_INTEGRATION_KEYS[]; @@ -45,6 +46,8 @@ export const checkIntegrationAvailability = (key: E_INTEGRATION_KEYS) => { return isSentryEnabled(); case E_INTEGRATION_KEYS.GITHUB_ENTERPRISE: return true; + case E_INTEGRATION_KEYS.DRAWIO_INTEGRATION: + return true; case E_INTEGRATION_KEYS.GITLAB_ENTERPRISE: return true; default: diff --git a/apps/silo/src/helpers/integration-connection-helper.ts b/apps/silo/src/helpers/integration-connection-helper.ts index cc36b3c965..26babf85f3 100644 --- a/apps/silo/src/helpers/integration-connection-helper.ts +++ b/apps/silo/src/helpers/integration-connection-helper.ts @@ -77,7 +77,7 @@ class IntegrationConnectionHelper { connection_type, }: { workspace_id: string; - connection_type: string; + connection_type?: string; }): Promise { return this.apiClient.workspaceConnection.listWorkspaceConnections({ workspace_id, diff --git a/apps/web/ce/hooks/editor/use-extended-editor-config.ts b/apps/web/ce/hooks/editor/use-extended-editor-config.ts new file mode 100644 index 0000000000..9ca7b74a08 --- /dev/null +++ b/apps/web/ce/hooks/editor/use-extended-editor-config.ts @@ -0,0 +1,23 @@ +import { useCallback } from "react"; +// plane imports +import type { TExtendedFileHandler } from "@plane/editor"; + +export type TExtendedEditorFileHandlersArgs = { + projectId?: string; + workspaceSlug: string; +}; + +export type TExtendedEditorConfig = { + getExtendedEditorFileHandlers: (args: TExtendedEditorFileHandlersArgs) => TExtendedFileHandler; +}; + +export const useExtendedEditorConfig = (): TExtendedEditorConfig => { + const getExtendedEditorFileHandlers: TExtendedEditorConfig["getExtendedEditorFileHandlers"] = useCallback( + () => ({}), + [] + ); + + return { + getExtendedEditorFileHandlers, + }; +}; diff --git a/apps/web/ce/hooks/use-editor-flagging.ts b/apps/web/ce/hooks/use-editor-flagging.ts index 0fc8a6eb4c..e2491e104c 100644 --- a/apps/web/ce/hooks/use-editor-flagging.ts +++ b/apps/web/ce/hooks/use-editor-flagging.ts @@ -15,6 +15,7 @@ export type TEditorFlaggingHookReturnType = { disabled: TExtensions[]; flagged: TExtensions[]; }; + isLoadingIntegrations: boolean; }; export type TEditorFlaggingHookProps = { @@ -38,4 +39,5 @@ export const useEditorFlagging = (props: TEditorFlaggingHookProps): TEditorFlagg disabled: ["ai", "collaboration-cursor"], flagged: [], }, + isLoadingIntegrations: false, }); diff --git a/apps/web/core/components/pages/editor/editor-body.tsx b/apps/web/core/components/pages/editor/editor-body.tsx index 86bdeabae4..66ed38b26f 100644 --- a/apps/web/core/components/pages/editor/editor-body.tsx +++ b/apps/web/core/components/pages/editor/editor-body.tsx @@ -97,6 +97,7 @@ export const PageEditorBody: React.FC = observer((props) => { const { data: currentUser } = useUser(); const { getWorkspaceBySlug } = useWorkspace(); const { getUserDetails } = useMember(); + // derived values const { id: pageId, @@ -121,7 +122,7 @@ export const PageEditorBody: React.FC = observer((props) => { searchEntity: handlers.fetchEntity, }); // editor flaggings - const { document: documentEditorExtensions } = useEditorFlagging({ + const { document: documentEditorExtensions, isLoadingIntegrations } = useEditorFlagging({ workspaceSlug, storeType, }); @@ -224,7 +225,7 @@ export const PageEditorBody: React.FC = observer((props) => { } ); - const isPageLoading = pageId === undefined || !realtimeConfig; + const isPageLoading = pageId === undefined || !realtimeConfig || isLoadingIntegrations; if (isPageLoading) return ; return ( diff --git a/apps/web/core/hooks/editor/use-editor-config.ts b/apps/web/core/hooks/editor/use-editor-config.ts index 99137f5e09..95fa14e4cd 100644 --- a/apps/web/core/hooks/editor/use-editor-config.ts +++ b/apps/web/core/hooks/editor/use-editor-config.ts @@ -5,6 +5,7 @@ import { getEditorAssetDownloadSrc, getEditorAssetSrc } from "@plane/utils"; // hooks import { useEditorAsset } from "@/hooks/store/use-editor-asset"; // plane web hooks +import { useExtendedEditorConfig } from "@/plane-web/hooks/editor/use-extended-editor-config"; import { useFileSize } from "@/plane-web/hooks/use-file-size"; // services import { FileService } from "@/services/file.service"; @@ -22,6 +23,8 @@ export const useEditorConfig = () => { const { assetsUploadPercentage } = useEditorAsset(); // file size const { maxFileSize } = useFileSize(); + // extended config + const { getExtendedEditorFileHandlers } = useExtendedEditorConfig(); const getEditorFileHandlers = useCallback( (args: TArgs): TFileHandler => { @@ -86,9 +89,10 @@ export const useEditorConfig = () => { validation: { maxFileSize, }, + ...getExtendedEditorFileHandlers({ projectId, workspaceSlug }), }; }, - [assetsUploadPercentage, maxFileSize] + [assetsUploadPercentage, getExtendedEditorFileHandlers, maxFileSize] ); return { diff --git a/apps/web/core/services/file.service.ts b/apps/web/core/services/file.service.ts index 5cb5a57e99..d51a143702 100644 --- a/apps/web/core/services/file.service.ts +++ b/apps/web/core/services/file.service.ts @@ -314,6 +314,62 @@ export class FileService extends APIService { }); } + async reuploadWorkspaceAsset( + workspaceSlug: string, + assetId: string, + file: File, + uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"] + ): Promise { + const fileMetaData = getFileMetaDataForUpload(file); + return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/reupload/${assetId}/`, { + type: fileMetaData.type, + size: fileMetaData.size, + }) + .then(async (response) => { + const signedURLResponse: TFileSignedURLResponse = response?.data; + const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file); + await this.fileUploadService.uploadFile( + signedURLResponse.upload_data.url, + fileUploadPayload, + uploadProgressHandler + ); + await this.updateWorkspaceAssetUploadStatus(workspaceSlug, signedURLResponse.asset_id); + return signedURLResponse; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + + async reuploadProjectAsset( + workspaceSlug: string, + projectId: string, + assetId: string, + file: File, + uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"] + ): Promise { + const fileMetaData = getFileMetaDataForUpload(file); + + return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/reupload/${assetId}/`, { + type: fileMetaData.type, + size: fileMetaData.size, + }) + .then(async (response) => { + const signedURLResponse: TFileSignedURLResponse = response?.data; + const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file); + await this.fileUploadService.uploadFile( + signedURLResponse.upload_data.url, + fileUploadPayload, + uploadProgressHandler + ); + await this.updateProjectAssetUploadStatus(workspaceSlug, projectId, signedURLResponse.asset_id); + return signedURLResponse; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + async getProjectCoverImages(): Promise { return this.get(`/api/project-covers/`) .then((res) => res?.data) diff --git a/apps/web/ee/components/pages/editor/external-embed/embed-handler.tsx b/apps/web/ee/components/pages/editor/external-embed/embed-handler.tsx index 7a59ce7b70..8037db9a8b 100644 --- a/apps/web/ee/components/pages/editor/external-embed/embed-handler.tsx +++ b/apps/web/ee/components/pages/editor/external-embed/embed-handler.tsx @@ -23,7 +23,7 @@ import { TwitterEmbed } from "@plane/ui/src/editor/twitter-embed"; // local hooks import { useUser } from "@/hooks/store/user"; // plane web services -import { iframelyService } from "@/plane-web/services/iframely.service"; +import { liveService } from "@/plane-web/services/live.service"; // Types type ErrorData = { @@ -54,7 +54,7 @@ const useEmbedDataManager = (externalEmbedNodeView: ExternalEmbedNodeViewProps) } = useSWR( swrKey, ([src, isThemeDark, workspaceSlug, userId]: [string, boolean, string, string]) => - iframelyService.getEmbedData(src, isThemeDark, workspaceSlug, userId), + liveService.getEmbedData(src, isThemeDark, workspaceSlug, userId), { revalidateOnFocus: false, revalidateOnReconnect: false, diff --git a/apps/web/ee/hooks/editor/use-extended-editor-config.ts b/apps/web/ee/hooks/editor/use-extended-editor-config.ts new file mode 100644 index 0000000000..09797471cc --- /dev/null +++ b/apps/web/ee/hooks/editor/use-extended-editor-config.ts @@ -0,0 +1,56 @@ +import { useCallback } from "react"; +// plane imports +import type { TFileSignedURLResponse } from "@plane/types"; +import { getEditorAssetSrc, getAssetIdFromUrl } from "@plane/utils"; +// ce imports +import type { TExtendedEditorConfig } from "@/ce/hooks/editor/use-extended-editor-config"; +// services +import { liveService } from "@/plane-web/services/live.service"; +import { FileService } from "@/services/file.service"; +const fileService = new FileService(); + +export const useExtendedEditorConfig = (): TExtendedEditorConfig => { + const getExtendedEditorFileHandlers: TExtendedEditorConfig["getExtendedEditorFileHandlers"] = useCallback( + ({ projectId, workspaceSlug }) => ({ + reupload: async (_blockId, file, assetSrc) => { + const assetId = getAssetIdFromUrl(assetSrc); + let response: TFileSignedURLResponse; + if (projectId) { + // Project-level asset reupload + response = await fileService.reuploadProjectAsset(workspaceSlug, projectId, assetId, file); + } else { + // Workspace-level asset reupload + response = await fileService.reuploadWorkspaceAsset(workspaceSlug, assetId, file); + } + return response.asset_id; + }, + getFileContent: async (xmlSrc) => { + if (!xmlSrc) return ""; + try { + let fileUrl: string; + if (xmlSrc.startsWith("http")) { + fileUrl = xmlSrc; + } else { + fileUrl = + getEditorAssetSrc({ + assetId: xmlSrc, + projectId, + workspaceSlug, + }) ?? ""; + } + if (!fileUrl) return ""; + + return liveService.getContent(fileUrl); + } catch (error) { + console.error("Error loading diagram content:", error); + return ""; + } + }, + }), + [] + ); + + return { + getExtendedEditorFileHandlers, + }; +}; diff --git a/apps/web/ee/hooks/pages/use-extended-editor-extensions.ts b/apps/web/ee/hooks/pages/use-extended-editor-extensions.ts index 0ef405e1b6..4d51840022 100644 --- a/apps/web/ee/hooks/pages/use-extended-editor-extensions.ts +++ b/apps/web/ee/hooks/pages/use-extended-editor-extensions.ts @@ -3,6 +3,7 @@ import { useMemo } from "react"; import type { IEditorPropsExtended, TCommentConfig } from "@plane/editor"; // hooks import type { TSearchEntityRequestPayload, TSearchResponse } from "@plane/types"; +import { LogoSpinner } from "@/components/common/logo-spinner"; import { useUserProfile } from "@/hooks/store/use-user-profile"; // plane web hooks import { useEditorEmbeds } from "@/plane-web/hooks/use-editor-embed"; @@ -11,7 +12,7 @@ import { EPageStoreType } from "../store"; export type TExtendedEditorExtensionsConfig = Pick< IEditorPropsExtended, - "embedHandler" | "commentConfig" | "isSmoothCursorEnabled" + "embedHandler" | "commentConfig" | "isSmoothCursorEnabled" | "logoSpinner" >; export type TExtendedEditorExtensionsHookParams = { @@ -49,6 +50,7 @@ export const useExtendedEditorProps = ( embedHandler: embedProps, commentConfig: extensionHandlers?.get("comments") as TCommentConfig | undefined, isSmoothCursorEnabled: is_smooth_cursor_enabled, + logoSpinner: LogoSpinner, }), [embedProps, extensionHandlers, is_smooth_cursor_enabled] ); diff --git a/apps/web/ee/hooks/use-editor-flagging.ts b/apps/web/ee/hooks/use-editor-flagging.ts index e74f332eb3..42b72b17b5 100644 --- a/apps/web/ee/hooks/use-editor-flagging.ts +++ b/apps/web/ee/hooks/use-editor-flagging.ts @@ -1,17 +1,28 @@ +import { useEffect, useMemo, useState } from "react"; // plane imports +import { SILO_BASE_PATH, SILO_BASE_URL } from "@plane/constants"; import type { TExtensions } from "@plane/editor"; // ce imports import type { TEditorFlaggingHookReturnType, TEditorFlaggingHookProps } from "@/ce/hooks/use-editor-flagging"; -// plane web hooks +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; +// lib import { store } from "@/lib/store-context"; +// plane web imports import { EPageStoreType, useFlag, usePageStore } from "@/plane-web/hooks/store"; +import { SiloAppService } from "@/plane-web/services/integrations/silo.service"; import { EWorkspaceFeatures } from "../types/workspace-feature"; +const siloAppService = new SiloAppService(encodeURI(SILO_BASE_URL + SILO_BASE_PATH)); /** * @description extensions disabled in various editors */ export const useEditorFlagging = (props: TEditorFlaggingHookProps): TEditorFlaggingHookReturnType => { const { workspaceSlug, storeType } = props; + // store hooks + const { getWorkspaceBySlug } = useWorkspace(); + const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id; + // feature flags const isWorkItemEmbedEnabled = useFlag(workspaceSlug, "PAGE_ISSUE_EMBEDS"); const isEditorAIOpsEnabled = useFlag(workspaceSlug, "EDITOR_AI_OPS") && @@ -21,61 +32,103 @@ export const useEditorFlagging = (props: TEditorFlaggingHookProps): TEditorFlagg const isEditorAttachmentsEnabled = useFlag(workspaceSlug, "EDITOR_ATTACHMENTS"); const isEditorMathematicsEnabled = useFlag(workspaceSlug, "EDITOR_MATHEMATICS"); const isExternalEmbedEnabled = useFlag(workspaceSlug, "EDITOR_EXTERNAL_EMBEDS"); + const [isLoadingIntegrations, setIsLoadingIntegrations] = useState(true); // disabled and flagged in the document editor - const documentDisabled: TExtensions[] = []; - const documentFlagged: TExtensions[] = []; + const document = useMemo( + () => ({ + disabled: new Set(), + flagged: new Set(), + }), + [] + ); // disabled and flagged in the rich text editor - const richTextDisabled: TExtensions[] = []; - const richTextFlagged: TExtensions[] = []; + const richText = useMemo( + () => ({ + disabled: new Set(), + flagged: new Set(), + }), + [] + ); // disabled and flagged in the lite text editor - const liteTextDisabled: TExtensions[] = []; - const liteTextFlagged: TExtensions[] = []; - - liteTextDisabled.push("external-embed"); + const liteText = useMemo( + () => ({ + disabled: new Set(["external-embed"]), + flagged: new Set(), + }), + [] + ); if (!isWorkItemEmbedEnabled) { - documentFlagged.push("issue-embed"); + document.flagged.add("issue-embed"); } if (!isEditorAIOpsEnabled) { - documentDisabled.push("ai"); + document.disabled.add("ai"); } if (!isCollaborationCursorEnabled) { - documentDisabled.push("collaboration-cursor"); + document.disabled.add("collaboration-cursor"); } if (storeType && !isNestedPagesEnabled(workspaceSlug)) { - documentFlagged.push("nested-pages"); + document.flagged.add("nested-pages"); } if (!isEditorAttachmentsEnabled) { - documentFlagged.push("attachments"); - richTextFlagged.push("attachments"); + document.flagged.add("attachments"); + richText.flagged.add("attachments"); } if (!isEditorMathematicsEnabled) { - documentFlagged.push("mathematics"); - richTextFlagged.push("mathematics"); - liteTextFlagged.push("mathematics"); + document.flagged.add("mathematics"); + richText.flagged.add("mathematics"); + liteText.flagged.add("mathematics"); } if (storeType && !isCommentsEnabled(workspaceSlug)) { - documentFlagged.push("comments"); + document.flagged.add("comments"); } if (!isExternalEmbedEnabled) { - documentFlagged.push("external-embed"); - richTextFlagged.push("external-embed"); - liteTextFlagged.push("external-embed"); + document.flagged.add("external-embed"); + richText.flagged.add("external-embed"); + liteText.flagged.add("external-embed"); } + // check for drawio integration + useEffect(() => { + const checkIntegrations = async () => { + if (!workspaceId) { + document.flagged.add("drawio"); + setIsLoadingIntegrations(false); + return; + } + + try { + const integrations = await siloAppService.getEnabledIntegrations(workspaceId); + const hasDrawio = integrations.some( + (integration: { connection_provider: TExtensions }) => integration.connection_provider === "drawio" + ); + if (!hasDrawio) { + document.flagged.add("drawio"); + } + } catch (_error) { + document.flagged.add("drawio"); + } finally { + setIsLoadingIntegrations(false); + } + }; + + checkIntegrations(); + }, [document, workspaceId]); + return { document: { - disabled: documentDisabled, - flagged: documentFlagged, + disabled: Array.from(document.disabled), + flagged: Array.from(document.flagged), }, liteText: { - disabled: liteTextDisabled, - flagged: liteTextFlagged, + disabled: Array.from(liteText.disabled), + flagged: Array.from(liteText.flagged), }, richText: { - disabled: richTextDisabled, - flagged: richTextFlagged, + disabled: Array.from(richText.disabled), + flagged: Array.from(richText.flagged), }, + isLoadingIntegrations, }; }; diff --git a/apps/web/ee/services/integrations/silo.service.ts b/apps/web/ee/services/integrations/silo.service.ts index 3854e04e50..6ee41cd0ba 100644 --- a/apps/web/ee/services/integrations/silo.service.ts +++ b/apps/web/ee/services/integrations/silo.service.ts @@ -18,4 +18,12 @@ export class SiloAppService { throw error?.response?.data; }); } + async getEnabledIntegrations(workspaceId: string) { + return this.axiosInstance + .get(`/api/apps/${workspaceId}/enabled-integrations/`) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } } diff --git a/apps/web/ee/services/iframely.service.ts b/apps/web/ee/services/live.service.ts similarity index 73% rename from apps/web/ee/services/iframely.service.ts rename to apps/web/ee/services/live.service.ts index 5c41c0e1e7..b262e494df 100644 --- a/apps/web/ee/services/iframely.service.ts +++ b/apps/web/ee/services/live.service.ts @@ -2,7 +2,7 @@ import { LIVE_URL } from "@plane/constants"; import { IframelyResponse } from "@plane/types"; import { APIService } from "@/services/api.service"; -export class IframelyService extends APIService { +export class LiveService extends APIService { constructor() { super(LIVE_URL); } @@ -32,7 +32,14 @@ export class IframelyService extends APIService { ); return response.data; } + + async getContent(url: string): Promise { + const response = await this.get(`/content`, { + params: { url: url }, + }); + return response.data.content; + } } // Create a singleton instance -export const iframelyService = new IframelyService(); +export const liveService = new LiveService(); diff --git a/packages/editor/src/ce/types/config.ts b/packages/editor/src/ce/types/config.ts new file mode 100644 index 0000000000..29693e9957 --- /dev/null +++ b/packages/editor/src/ce/types/config.ts @@ -0,0 +1 @@ +export type TExtendedFileHandler = object; diff --git a/packages/editor/src/ce/types/index.ts b/packages/editor/src/ce/types/index.ts index 7efa23c054..c2c5d6dfaf 100644 --- a/packages/editor/src/ce/types/index.ts +++ b/packages/editor/src/ce/types/index.ts @@ -1,2 +1,3 @@ export * from "./issue-embed"; export * from "./editor-extended"; +export * from "./config"; diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 4f979dc2d7..a6d44fe431 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -25,6 +25,7 @@ const generalSelectors = [ ".editor-attachment-component", ".page-embed-component", ".editor-mathematics-component", + ".editor-drawio-component", ].join(", "); const maxScrollSpeed = 20; diff --git a/packages/editor/src/core/types/config.ts b/packages/editor/src/core/types/config.ts index 5a1402332e..dda8b45a36 100644 --- a/packages/editor/src/core/types/config.ts +++ b/packages/editor/src/core/types/config.ts @@ -1,5 +1,6 @@ // plane imports import { TWebhookConnectionQueryParams } from "@plane/types"; +import { TExtendedFileHandler } from "@/plane-editor/types/config"; export type TFileHandler = { assetsUploadStatus: Record; // blockId => progress percentage @@ -17,7 +18,7 @@ export type TFileHandler = { */ maxFileSize: number; }; -}; +} & TExtendedFileHandler; export type TEditorFontStyle = "sans-serif" | "serif" | "monospace"; diff --git a/packages/editor/src/core/types/extensions.ts b/packages/editor/src/core/types/extensions.ts index 3c739895d2..84a707cc87 100644 --- a/packages/editor/src/core/types/extensions.ts +++ b/packages/editor/src/core/types/extensions.ts @@ -10,4 +10,5 @@ export type TExtensions = | "external-embed" | "attachments" | "comments" - | "mathematics"; + | "mathematics" + | "drawio"; diff --git a/packages/editor/src/ee/constants/awareness.ts b/packages/editor/src/ee/constants/awareness.ts new file mode 100644 index 0000000000..96f1929b2f --- /dev/null +++ b/packages/editor/src/ee/constants/awareness.ts @@ -0,0 +1,4 @@ +export enum EAwarenessKeys { + DRAWIO_EDITING = "drawioEditing", + DRAWIO_UPDATE = "drawioUpdate", +} diff --git a/packages/editor/src/ee/constants/extensions.ts b/packages/editor/src/ee/constants/extensions.ts index 101e849601..6fe7808c5a 100644 --- a/packages/editor/src/ee/constants/extensions.ts +++ b/packages/editor/src/ee/constants/extensions.ts @@ -8,4 +8,5 @@ export enum ADDITIONAL_EXTENSIONS { BLOCK_MATH = "blockMath", EXTERNAL_EMBED = "externalEmbedComponent", PAGE_LINK_COMPONENT = "pageLinkComponent", + DRAWIO = "drawIoComponent", } diff --git a/packages/editor/src/ee/extensions/core/without-props.ts b/packages/editor/src/ee/extensions/core/without-props.ts index 74a6efbec5..05065f8488 100644 --- a/packages/editor/src/ee/extensions/core/without-props.ts +++ b/packages/editor/src/ee/extensions/core/without-props.ts @@ -1,6 +1,7 @@ import { Extensions } from "@tiptap/core"; import { CustomAttachmentExtensionConfig } from "../attachments/extension-config"; import { CommentsExtensionConfig } from "../comments/extension-config"; +import { DrawioExtensionConfig } from "../drawio/extension-config"; import { ExternalEmbedExtensionConfig } from "../external-embed/extension-config"; import { MathematicsExtensionConfig } from "../mathematics/extension-config"; import { PageEmbedExtensionConfig } from "../page-embed/extension-config"; @@ -10,6 +11,7 @@ export const CoreEditorAdditionalExtensionsWithoutProps: Extensions = [ CustomAttachmentExtensionConfig, MathematicsExtensionConfig, CommentsExtensionConfig, + DrawioExtensionConfig, ]; export const DocumentEditorAdditionalExtensionsWithoutProps: Extensions = [PageEmbedExtensionConfig]; diff --git a/packages/editor/src/ee/extensions/document-extensions.tsx b/packages/editor/src/ee/extensions/document-extensions.tsx index 922c163106..7903014bc7 100644 --- a/packages/editor/src/ee/extensions/document-extensions.tsx +++ b/packages/editor/src/ee/extensions/document-extensions.tsx @@ -1,5 +1,5 @@ import { AnyExtension, Extensions } from "@tiptap/core"; -import { FileText, Paperclip } from "lucide-react"; +import { FileText, Paperclip, PenTool, Presentation } from "lucide-react"; // plane imports import { LayersIcon } from "@plane/propel/icons"; import { ADDITIONAL_EXTENSIONS } from "@plane/utils"; @@ -18,8 +18,11 @@ import { insertAttachment } from "../helpers/editor-commands"; import { CustomAttachmentExtension } from "./attachments/extension"; import { CustomCollaborationCursor } from "./collaboration-cursor"; import { CommentsExtension } from "./comments"; +import { DrawioExtension } from "./drawio/extension"; +import { EDrawioMode } from "./drawio/types"; /** + * Registry for slash commands * Each entry defines a single slash command option with its own enabling logic */ @@ -100,6 +103,41 @@ const slashCommandRegistry: { pushAfter: "image", }), }, + { + // Draw.io diagram slash command + isEnabled: (disabledExtensions, flaggedExtensions) => + !disabledExtensions.includes("drawio") && !flaggedExtensions.includes("drawio"), + getOption: () => ({ + commandKey: "drawio-diagram", + key: "drawio", + title: "Draw.io diagram", + description: "Create diagrams, flowcharts, and visual documentation.", + searchTerms: ["draw.io", "diagram", "flowchart", "chart", "visual", "drawing"], + icon: , + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).insertDrawioDiagram({ mode: EDrawioMode.DIAGRAM }).run(); + }, + section: "general", + pushAfter: "attachment", + }), + }, + { + isEnabled: (disabledExtensions, flaggedExtensions) => + !disabledExtensions.includes("drawio") && !flaggedExtensions.includes("drawio"), + getOption: () => ({ + commandKey: "drawio-board", + key: "drawio-board", + title: "Draw.io board", + description: "Create whiteboards with freehand drawing and collaboration.", + searchTerms: ["draw.io", "board", "whiteboard", "sketch", "brainstorm", "collaboration", "kanban"], + icon: , + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).insertDrawioDiagram({ mode: EDrawioMode.BOARD }).run(); + }, + section: "general", + pushAfter: "drawio-diagram", + }), + }, ]; /** @@ -211,6 +249,16 @@ const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [ }); }, }, + { + // Draw.io extension + isEnabled: (disabledExtensions) => !disabledExtensions.includes("drawio"), + getExtension: ({ flaggedExtensions, fileHandler, extendedEditorProps }) => + DrawioExtension({ + isFlagged: flaggedExtensions.includes("drawio"), + fileHandler, + logoSpinner: extendedEditorProps?.logoSpinner, + }), + }, ]; /** diff --git a/packages/editor/src/ee/extensions/drawio/commands.ts b/packages/editor/src/ee/extensions/drawio/commands.ts new file mode 100644 index 0000000000..ba19c97eb6 --- /dev/null +++ b/packages/editor/src/ee/extensions/drawio/commands.ts @@ -0,0 +1,34 @@ +import type { Commands } from "@tiptap/core"; +import type { NodeType } from "@tiptap/pm/model"; +import { v4 as uuidv4 } from "uuid"; +import { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions"; +import { EDrawioAttributeNames, TDrawioBlockAttributes } from "./types"; +import { DEFAULT_DRAWIO_ATTRIBUTES } from "./utils/attribute"; + +export const drawioCommands = (nodeType: NodeType): Commands[ADDITIONAL_EXTENSIONS.DRAWIO] => ({ + insertDrawioDiagram: + (props) => + ({ commands }) => { + const uniqueID = uuidv4(); + + const attributes: TDrawioBlockAttributes = { + ...DEFAULT_DRAWIO_ATTRIBUTES, + [EDrawioAttributeNames.ID]: uniqueID, + [EDrawioAttributeNames.MODE]: props.mode, + }; + + if (props.pos) { + commands.insertContentAt(props.pos, { + type: nodeType.name, + attrs: attributes, + }); + } else { + commands.insertContent({ + type: nodeType.name, + attrs: attributes, + }); + } + + return true; + }, +}); diff --git a/packages/editor/src/ee/extensions/drawio/components/block.tsx b/packages/editor/src/ee/extensions/drawio/components/block.tsx new file mode 100644 index 0000000000..10e3178e57 --- /dev/null +++ b/packages/editor/src/ee/extensions/drawio/components/block.tsx @@ -0,0 +1,205 @@ +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@plane/utils"; +// hooks +import { useDrawioAwareness } from "../hooks/use-awareness"; +import { useDrawioMessageHandler } from "../hooks/use-drawio-message-handler"; +// types +import { EDrawioAttributeNames, EDrawioMode } from "../types"; +// constants +import { DRAWIO_DIAGRAM_URL, DRAWIO_BOARD_URL } from "../utils/constants"; +// components +import { DrawioIframe, DrawioIframeRef } from "./iframe"; +import { DrawioInputBlock } from "./input-block"; +import { DrawioIframeLoading } from "./loading"; +import { DrawioNodeViewProps } from "./node-view"; +import { DrawioDialogWrapper } from "./wrapper"; + +export const DrawioBlock: React.FC = memo((props) => { + // props + const { node, updateAttributes, editor, selected, extension } = props; + // state + const [isModalOpen, setIsModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [resolvedImageSrc, setResolvedImageSrc] = useState(undefined); + + // refs + const iframeRef = useRef(null); + // attribute + const diagramId = node.attrs[EDrawioAttributeNames.ID]; + const imageSrc = node.attrs[EDrawioAttributeNames.IMAGE_SRC]; + const xmlSrc = node.attrs[EDrawioAttributeNames.XML_SRC]; + const mode = node.attrs[EDrawioAttributeNames.MODE]; + // hooks + const { + userEditingThisDiagram, + setEditingState, + broadcastDiagramUpdate, + liveImageData, + imageKey, + failedToLoadDiagram, + clearLiveImageData, + updateImageKey, + updateLiveImageData, + setDiagramError, + handleBlockedClick, + } = useDrawioAwareness(editor, diagramId || null); + + // Check if diagram is uploaded (has imageSrc attribute) + const isUploaded = !!imageSrc; + + // Resolve the SVG source URL for display + useEffect(() => { + if (!imageSrc) { + setResolvedImageSrc(undefined); + clearLiveImageData(); // Clear live data when no image source + return; + } + + const getSvgSource = async () => { + try { + const url = await extension?.options.getDiagramSrc?.(imageSrc); + setResolvedImageSrc(url); + setDiagramError(false); + } catch (_error) { + setDiagramError(true); + } + }; + + getSvgSource(); + }, [imageSrc, extension?.options, imageKey, clearLiveImageData, setDiagramError]); + + // Load XML content for editing + const loadXmlContent = useCallback(async (): Promise => { + const getFileContent = extension?.options.getFileContent; + if (!xmlSrc || !getFileContent) return ""; + + try { + return await getFileContent(xmlSrc); + } catch (error) { + console.error("Error loading XML content:", error); + return ""; + } + }, [xmlSrc, extension?.options]); + + const handleCloseModal = useCallback(() => { + setEditingState(false); + setIsModalOpen(false); + setIsLoading(false); + }, [setEditingState]); + + // Message handler hook + const { handleMessage } = useDrawioMessageHandler({ + diagramId: diagramId || undefined, + imageSrc: imageSrc || undefined, + xmlSrc: xmlSrc || undefined, + iframeRef, + loadXmlContent, + handleCloseModal, + setIsLoading, + updateLiveImageData, + updateImageKey, + broadcastDiagramUpdate, + updateAttributes, + extension, + }); + + const handleClick = useCallback( + (evt: React.MouseEvent) => { + evt.preventDefault(); + evt.stopPropagation(); + + if (!editor.isEditable || extension.options.isFlagged || userEditingThisDiagram) return; + + setEditingState(true); + setIsModalOpen(true); + setIsLoading(true); + }, + [editor.isEditable, extension.options.isFlagged, setEditingState, userEditingThisDiagram] + ); + + const getClickHandler = useCallback(() => { + if (userEditingThisDiagram) return handleBlockedClick; + if (extension.options.isFlagged) return undefined; + return handleClick; + }, [userEditingThisDiagram, handleBlockedClick, extension.options.isFlagged, handleClick]); + + // If failed to load, show error state + if (failedToLoadDiagram && isUploaded) { + return ( +

+
+ Failed to load diagram +
+
+ ); + } + + return ( + <> +
+ {/* Editing labels */} + {userEditingThisDiagram && ( +
+ {userEditingThisDiagram.name} is editing +
+ )} + + {userEditingThisDiagram && ( +
+ )} + + {isUploaded && (liveImageData || resolvedImageSrc) ? ( + Drawio diagram { + setDiagramError(true); + }} + /> + ) : ( + + )} +
+ + +
+ + {isLoading && } +
+
+ + ); +}); diff --git a/packages/editor/src/ee/extensions/drawio/components/iframe.tsx b/packages/editor/src/ee/extensions/drawio/components/iframe.tsx new file mode 100644 index 0000000000..c2fc65d0da --- /dev/null +++ b/packages/editor/src/ee/extensions/drawio/components/iframe.tsx @@ -0,0 +1,54 @@ +import { useRef, useEffect, forwardRef, useImperativeHandle } from "react"; +import { cn } from "@plane/utils"; + +type DrawioIframeProps = { + src: string; + onMessage?: (event: MessageEvent) => void; + isVisible?: boolean; +}; + +export type DrawioIframeRef = { + postMessage: (message: string) => void; + showIframe: () => void; + hideIframe: () => void; +}; + +export const DrawioIframe = forwardRef( + ({ src, onMessage, isVisible = false }, ref) => { + const iframeRef = useRef(null); + + useEffect(() => { + if (onMessage) { + window.addEventListener("message", onMessage); + return () => { + window.removeEventListener("message", onMessage); + }; + } + }, [onMessage]); + + useImperativeHandle(ref, () => ({ + postMessage: (message: string) => { + iframeRef.current?.contentWindow?.postMessage(message, "*"); + }, + showIframe: () => { + if (iframeRef.current?.style) iframeRef.current.style.opacity = "1"; + }, + hideIframe: () => { + if (iframeRef.current?.style) iframeRef.current.style.opacity = "0"; + }, + })); + + return ( +