diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index a75afc223..e5fae1bed 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -45,6 +45,7 @@ import TemplateEditor from "@/ee/template/pages/template-editor"; import FavoritesPage from "@/pages/favorites/favorites-page"; import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx"; import VerifyEmail from "@/ee/pages/verify-email.tsx"; +import ConfluenceImportPage from "@/ee/confluence-import/pages/confluence-import.tsx"; export default function App() { const { t } = useTranslation(); @@ -123,6 +124,10 @@ export default function App() { } /> } /> } /> + } + /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 90d89d13e..6de6890e8 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -15,6 +15,7 @@ import { IconSparkles, IconHistory, IconShieldCheck, + IconFileImport, } from "@tabler/icons-react"; import { Link, useLocation } from "react-router-dom"; import classes from "./settings.module.css"; @@ -124,6 +125,13 @@ const groupedData: DataGroup[] = [ role: "owner", env: "selfhosted", }, + { + label: "Import", + icon: IconFileImport, + path: "/settings/import/confluence", + feature: Feature.CONFLUENCE_IMPORT, + role: "admin", + }, ], }, { diff --git a/apps/client/src/ee/confluence-import/components/confluence-import-history.tsx b/apps/client/src/ee/confluence-import/components/confluence-import-history.tsx new file mode 100644 index 000000000..6c8acdc1d --- /dev/null +++ b/apps/client/src/ee/confluence-import/components/confluence-import-history.tsx @@ -0,0 +1,205 @@ +import { useMemo } from "react"; +import { + Badge, + Group, + Loader, + Progress, + Skeleton, + Stack, + Table, + Text, + Tooltip, +} from "@mantine/core"; +import { IconAlertCircle, IconCheck, IconX } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { + ConfluenceImportHistoryItem, + ConfluenceImportStatus, +} from "@/ee/confluence-import/types/confluence-import.types"; +import { CustomAvatar } from "@/components/ui/custom-avatar"; +import { formattedDate } from "@/lib/time"; +import NoTableResults from "@/components/common/no-table-results"; +import { useConfluenceImportsQuery } from "@/ee/confluence-import/queries/confluence-import-queries"; + +function statusBadge(status: ConfluenceImportStatus, cancelled: boolean) { + if (cancelled) { + return ( + }> + Cancelled + + ); + } + if (status === "processing") { + return ( + }> + Running + + ); + } + if (status === "success") { + return ( + }> + Completed + + ); + } + return ( + } + > + Failed + + ); +} + +function phaseLabel(phase: string | null): string { + if (!phase) return "—"; + return phase.charAt(0).toUpperCase() + phase.slice(1); +} + +function progressValue(item: ConfluenceImportHistoryItem) { + if (item.status === "success") return 100; + if (item.totalPages > 0) { + return Math.min( + 100, + Math.round((item.importedPages / item.totalPages) * 100), + ); + } + return item.status === "processing" ? 5 : 0; +} + +function ProgressCell({ item }: { item: ConfluenceImportHistoryItem }) { + const value = progressValue(item); + const color = + item.status === "failed" + ? "red" + : item.status === "success" + ? "teal" + : "blue"; + + return ( + + + + + {item.importedPages}/{item.totalPages || "?"} pages + + + · {item.importedSpaces}/{item.totalSpaces || "?"} spaces + + + + ); +} + +function TableSkeleton() { + return ( + <> + {Array.from({ length: 3 }).map((_, i) => ( + + + + + + + + + + + + + + + + + + + + + ))} + + ); +} + +export default function ConfluenceImportHistory() { + const { t } = useTranslation(); + const { data, isLoading } = useConfluenceImportsQuery(); + + const items = useMemo(() => data?.items ?? [], [data]); + + return ( + + + + + {t("Status")} + {t("Site")} + {t("Phase")} + {t("Progress")} + {t("Started by")} + {t("Started at")} + + + + + {isLoading ? ( + + ) : items.length > 0 ? ( + items.map((item) => ( + + + {statusBadge(item.status, item.cancelled)} + {item.status === "failed" && item.errorMessage && ( + + + {item.errorMessage} + + + )} + + + + {item.siteUrl} + + + + {phaseLabel(item.currentPhase)} + + + + + + {item.creatorName ? ( + + + + {item.creatorName} + + + ) : ( + + — + + )} + + + + {formattedDate(new Date(item.createdAt))} + + + + )) + ) : ( + + )} + +
+
+ ); +} diff --git a/apps/client/src/ee/confluence-import/components/confluence-import-modal.tsx b/apps/client/src/ee/confluence-import/components/confluence-import-modal.tsx new file mode 100644 index 000000000..39b3a502a --- /dev/null +++ b/apps/client/src/ee/confluence-import/components/confluence-import-modal.tsx @@ -0,0 +1,441 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Alert, + Button, + Checkbox, + Group, + Modal, + PasswordInput, + ScrollArea, + SegmentedControl, + Stack, + Stepper, + Text, + TextInput, +} from "@mantine/core"; +import { + IconAlertCircle, + IconCheck, + IconCloudCheck, + IconPlug, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useForm } from "@mantine/form"; +import { notifications } from "@mantine/notifications"; +import { useQueryClient } from "@tanstack/react-query"; +import { + listConfluenceSpaces, + startConfluenceImport, + testConfluenceConnection, +} from "@/ee/confluence-import/services/confluence-import-service"; +import { + ConfluenceAuthType, + ConfluenceCredentials, + ConfluenceSpaceSummary, +} from "@/ee/confluence-import/types/confluence-import.types"; +import { confluenceImportsQueryKey } from "@/ee/confluence-import/queries/confluence-import-queries"; + +type ConfluenceEditionChoice = "cloud" | "server"; + +type CredentialsFormValues = { + edition: ConfluenceEditionChoice; + authType: ConfluenceAuthType; + siteUrl: string; + email: string; + token: string; + username: string; + password: string; +}; + +type Props = { + opened: boolean; + onClose: () => void; +}; + +export default function ConfluenceImportModal({ opened, onClose }: Props) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [active, setActive] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [spaces, setSpaces] = useState([]); + const [selectedKeys, setSelectedKeys] = useState([]); + const [importAll, setImportAll] = useState(true); + + const form = useForm({ + initialValues: { + edition: "server", + authType: "pat", + siteUrl: "", + email: "", + token: "", + username: "", + password: "", + }, + validate: { + siteUrl: (value) => + !value?.trim() + ? t("Site URL is required") + : !/^https?:\/\//i.test(value.trim()) + ? t("Site URL must start with http:// or https://") + : null, + email: (value, values) => + values.edition === "cloud" && !value?.trim() + ? t("Email is required") + : null, + token: (value, values) => + (values.authType === "cloud_token" || values.authType === "pat") && + !value?.trim() + ? t("API token is required") + : null, + username: (value, values) => + values.authType === "basic" && !value?.trim() + ? t("Username is required") + : null, + password: (value, values) => + values.authType === "basic" && !value?.trim() + ? t("Password is required") + : null, + }, + }); + + useEffect(() => { + if (!opened) { + setActive(0); + setError(null); + setSpaces([]); + setSelectedKeys([]); + setImportAll(true); + setLoading(false); + form.reset(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [opened]); + + const credentials: ConfluenceCredentials = useMemo(() => { + const values = form.values; + return { + siteUrl: values.siteUrl.trim().replace(/\/+$/, ""), + authType: values.authType, + email: values.email?.trim() || undefined, + token: values.token?.trim() || undefined, + username: values.username?.trim() || undefined, + password: values.password || undefined, + }; + }, [form.values]); + + const handleEditionChange = (edition: ConfluenceEditionChoice) => { + form.setFieldValue("edition", edition); + if (edition === "cloud") { + form.setFieldValue("authType", "cloud_token"); + } else if (form.values.authType === "cloud_token") { + form.setFieldValue("authType", "pat"); + } + }; + + const handleNextFromCredentials = async () => { + if (form.validate().hasErrors) return; + setLoading(true); + setError(null); + try { + const test = await testConfluenceConnection(credentials); + if (!test.success) { + setError(test.error || t("Connection failed")); + return; + } + const list = await listConfluenceSpaces(credentials); + if (!list.success || !list.spaces) { + setError(list.error || t("Failed to load spaces")); + return; + } + setSpaces(list.spaces); + setSelectedKeys(list.spaces.map((s) => s.key)); + setImportAll(true); + setActive(1); + } catch (err: any) { + setError(err?.response?.data?.message || err?.message || t("Unexpected error")); + } finally { + setLoading(false); + } + }; + + const toggleSpace = (key: string, checked: boolean) => { + setSelectedKeys((prev) => + checked ? Array.from(new Set([...prev, key])) : prev.filter((k) => k !== key), + ); + }; + + const toggleAll = (checked: boolean) => { + setImportAll(checked); + setSelectedKeys(checked ? spaces.map((s) => s.key) : []); + }; + + const handleStartImport = async () => { + const spaceKeys = importAll ? [] : selectedKeys; + if (!importAll && spaceKeys.length === 0) { + setError(t("Select at least one space to import")); + return; + } + + setLoading(true); + setError(null); + try { + const result = await startConfluenceImport({ + ...credentials, + spaceKeys, + }); + if (!result.success || !result.fileTaskId) { + setError(result.error || t("Failed to start import")); + setLoading(false); + return; + } + + await queryClient.invalidateQueries({ + queryKey: confluenceImportsQueryKey, + }); + + notifications.show({ + title: t("Confluence import started"), + message: t("Track progress below. This runs in the background."), + color: "blue", + icon: , + autoClose: 4000, + }); + + onClose(); + } catch (err: any) { + setError( + err?.response?.data?.message || err?.message || t("Unexpected error"), + ); + setLoading(false); + } + }; + + const handleCancelFlow = async () => { + onClose(); + }; + + const editionSegment = ( + handleEditionChange(val as ConfluenceEditionChoice)} + data={[ + { value: "server", label: t("Data Center / Server") }, + { value: "cloud", label: t("Cloud") }, + ]} + fullWidth + /> + ); + + const authTypeSegment = form.values.edition === "server" && ( + + form.setFieldValue("authType", val as ConfluenceAuthType) + } + data={[ + { value: "pat", label: t("Personal Access Token") }, + { value: "basic", label: t("Username + password") }, + ]} + fullWidth + /> + ); + + const selectedCount = importAll ? spaces.length : selectedKeys.length; + + return ( + + + } + /> + } + /> + + + {active === 0 && ( + + + {t( + "Enter your Confluence URL and credentials. We'll validate the connection before continuing.", + )} + + {editionSegment} + {authTypeSegment} + + + {form.values.edition === "cloud" && ( + <> + + + + )} + + {form.values.edition === "server" && + form.values.authType === "pat" && ( + <> + + + + )} + + {form.values.edition === "server" && + form.values.authType === "basic" && ( + <> + + + + + )} + + {error && ( + }> + {error} + + )} + + + + + + + )} + + {active === 1 && ( + + + {t( + "Choose the spaces to import. Users, groups and permissions will be imported for the selected spaces.", + )} + + + toggleAll(e.currentTarget.checked)} + /> + + + + {spaces.map((space) => ( + + {space.name} + + ({space.key}) + + + } + checked={importAll || selectedKeys.includes(space.key)} + disabled={importAll} + onChange={(e) => + toggleSpace(space.key, e.currentTarget.checked) + } + /> + ))} + {spaces.length === 0 && ( + + {t("No spaces found for this account.")} + + )} + + + + {error && ( + }> + {error} + + )} + + + + {t("{{count}} selected", { count: selectedCount })} + + + + + + + + )} + + + ); +} diff --git a/apps/client/src/ee/confluence-import/pages/confluence-import.tsx b/apps/client/src/ee/confluence-import/pages/confluence-import.tsx new file mode 100644 index 000000000..9e48bd7e9 --- /dev/null +++ b/apps/client/src/ee/confluence-import/pages/confluence-import.tsx @@ -0,0 +1,67 @@ +import { Helmet } from "react-helmet-async"; +import { useTranslation } from "react-i18next"; +import { + Button, + Divider, + Group, + Paper, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import SettingsTitle from "@/components/settings/settings-title"; +import { ConfluenceIcon } from "@/components/icons/confluence-icon"; +import ConfluenceImportModal from "@/ee/confluence-import/components/confluence-import-modal"; +import ConfluenceImportHistory from "@/ee/confluence-import/components/confluence-import-history"; +import { getAppName } from "@/lib/config"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; + +export default function ConfluenceImportPage() { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const hasConfluenceImport = useHasFeature(Feature.CONFLUENCE_IMPORT); + const upgradeLabel = useUpgradeLabel(); + + return ( + <> + + + {t("Import from Confluence")} - {getAppName()} + + + + + + + + + + + {t("Confluence API import")} + + {t( + "Connect to Confluence Cloud or Data Center to import spaces, pages, attachments, comments, users, groups and permissions directly via the API.", + )} + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/client/src/ee/confluence-import/queries/confluence-import-queries.ts b/apps/client/src/ee/confluence-import/queries/confluence-import-queries.ts new file mode 100644 index 000000000..6cf7921bb --- /dev/null +++ b/apps/client/src/ee/confluence-import/queries/confluence-import-queries.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import { listConfluenceImports } from "@/ee/confluence-import/services/confluence-import-service"; + +export const confluenceImportsQueryKey = ["confluence-imports"] as const; + +export function useConfluenceImportsQuery() { + return useQuery({ + queryKey: confluenceImportsQueryKey, + queryFn: listConfluenceImports, + refetchInterval: (query) => { + const hasRunning = query.state.data?.items?.some( + (i) => i.status === "processing", + ); + return hasRunning ? 3000 : false; + }, + }); +} diff --git a/apps/client/src/ee/confluence-import/services/confluence-import-service.ts b/apps/client/src/ee/confluence-import/services/confluence-import-service.ts new file mode 100644 index 000000000..14cc4619d --- /dev/null +++ b/apps/client/src/ee/confluence-import/services/confluence-import-service.ts @@ -0,0 +1,64 @@ +import api from "@/lib/api-client"; +import { + ConfluenceCredentials, + ImportStatusResponse, + ListImportsResponse, + ListSpacesResponse, + StartImportResponse, + TestConnectionResponse, +} from "@/ee/confluence-import/types/confluence-import.types"; + +export async function testConfluenceConnection( + data: ConfluenceCredentials, +): Promise { + const req = await api.post( + "/confluence-import/test-connection", + data, + ); + return req.data; +} + +export async function listConfluenceSpaces( + data: ConfluenceCredentials, +): Promise { + const req = await api.post( + "/confluence-import/spaces", + data, + ); + return req.data; +} + +export async function startConfluenceImport( + data: ConfluenceCredentials & { spaceKeys?: string[] }, +): Promise { + const req = await api.post( + "/confluence-import/start", + data, + ); + return req.data; +} + +export async function getConfluenceImportStatus( + fileTaskId: string, +): Promise { + const req = await api.post( + "/confluence-import/status", + { fileTaskId }, + ); + return req.data; +} + +export async function listConfluenceImports(): Promise { + const req = await api.post("/confluence-import/list"); + return req.data; +} + +export async function cancelConfluenceImport( + fileTaskId: string, +): Promise<{ success: boolean }> { + const req = await api.post<{ success: boolean }>( + "/confluence-import/cancel", + { fileTaskId }, + ); + return req.data; +} diff --git a/apps/client/src/ee/confluence-import/types/confluence-import.types.ts b/apps/client/src/ee/confluence-import/types/confluence-import.types.ts new file mode 100644 index 000000000..d70ab5a3e --- /dev/null +++ b/apps/client/src/ee/confluence-import/types/confluence-import.types.ts @@ -0,0 +1,82 @@ +export type ConfluenceAuthType = "cloud_token" | "pat" | "basic"; + +export type ConfluenceCredentials = { + siteUrl: string; + authType: ConfluenceAuthType; + email?: string; + token?: string; + username?: string; + password?: string; +}; + +export type ConfluenceSpaceSummary = { + id: string; + key: string; + name: string; + type?: string; + status?: string; +}; + +export type TestConnectionResponse = { + success: boolean; + edition?: string; + spaceCount?: number; + error?: string; +}; + +export type ListSpacesResponse = { + success: boolean; + spaces?: ConfluenceSpaceSummary[]; + error?: string; +}; + +export type StartImportResponse = { + success: boolean; + fileTaskId?: string; + error?: string; +}; + +export type ConfluenceImportStatus = "processing" | "success" | "failed"; + +export type ImportStatusResponse = { + fileTaskId?: string; + status?: ConfluenceImportStatus; + errorMessage?: string | null; + currentPhase?: string | null; + totalSpaces?: number; + importedSpaces?: number; + totalPages?: number; + importedPages?: number; + totalUsers?: number; + importedUsers?: number; + warnings?: string[]; + createdAt?: string; + updatedAt?: string; + error?: string; +}; + +export type ConfluenceImportHistoryItem = { + fileTaskId: string; + siteUrl: string; + status: ConfluenceImportStatus; + errorMessage: string | null; + currentPhase: string | null; + totalSpaces: number; + importedSpaces: number; + totalPages: number; + importedPages: number; + totalUsers: number; + importedUsers: number; + cancelled: boolean; + spaceKeys: string[]; + warnings: string[]; + createdAt: string; + updatedAt: string; + creatorId: string | null; + creatorName: string | null; + creatorAvatarUrl: string | null; +}; + +export type ListImportsResponse = { + items: ConfluenceImportHistoryItem[]; +}; diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index e6f9de48c..48739f80f 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -55,6 +55,7 @@ export default defineConfig(({ mode }) => { }, }, server: { + allowedHosts: ['docmost.nz'], proxy: { "/api": { target: APP_URL, diff --git a/apps/server/src/database/types/custom.types.ts b/apps/server/src/database/types/custom.types.ts index 2f7acaef3..cde6ea7bf 100644 --- a/apps/server/src/database/types/custom.types.ts +++ b/apps/server/src/database/types/custom.types.ts @@ -18,6 +18,7 @@ export interface ConfluenceApiImports { warnings: Generated; currentPhase: string | null; cancelled: Generated; + spaceKeys: Generated; workspaceId: string; creatorId: string | null; createdAt: Generated; diff --git a/apps/server/src/ee b/apps/server/src/ee index 7d1b155d5..ae15159b8 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 7d1b155d5f8651c6297d08b200ca8b85502a827a +Subproject commit ae15159b8d3811d85b2b52384ced21e0dc6e007d diff --git a/apps/server/src/integrations/import/processors/file-task.processor.ts b/apps/server/src/integrations/import/processors/file-task.processor.ts index 03527707b..35c722461 100644 --- a/apps/server/src/integrations/import/processors/file-task.processor.ts +++ b/apps/server/src/integrations/import/processors/file-task.processor.ts @@ -28,6 +28,9 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy { case QueueJob.IMPORT_TASK: await this.fileTaskService.processZIpImport(job.data.fileTaskId); break; + case QueueJob.CONFLUENCE_API_IMPORT: + await this.processConfluenceApiImport(job.data.fileTaskId); + break; case QueueJob.PDF_EXPORT_TASK: await this.processExportTask(job.data.fileTaskId); break; @@ -49,6 +52,19 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy { }); } + private getConfluenceApiImportService() { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = require('./../../../ee/confluence-api-import/confluence-api-import.service'); + return this.moduleRef.get(mod.ConfluenceApiImportService, { + strict: false, + }); + } + + private async processConfluenceApiImport(fileTaskId: string): Promise { + const service = this.getConfluenceApiImportService(); + await service.processImport(fileTaskId); + } + private async processExportTask(fileTaskId: string): Promise { const pdfExportService = this.getPdfExportService(); await pdfExportService.generateAndStorePdf(fileTaskId); @@ -74,6 +90,8 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy { await this.handleFailedImportJob(job); } else if (job.name === QueueJob.PDF_EXPORT_TASK) { await this.handleFailedExportJob(job); + } else if (job.name === QueueJob.CONFLUENCE_API_IMPORT) { + await this.handleFailedExportJob(job); } } diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index c783ec05e..77d70adf4 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -30,6 +30,7 @@ export enum QueueJob { FIRST_PAYMENT_EMAIL = 'first-payment-email', IMPORT_TASK = 'import-task', + CONFLUENCE_API_IMPORT = 'confluence-api-import-task', EXPORT_TASK = 'export-task', SEARCH_INDEX_PAGE = 'search-index-page',