mirror of
https://github.com/docmost/docmost.git
synced 2026-05-20 16:44:05 +08:00
fix
This commit is contained in:
@@ -45,6 +45,7 @@ import TemplateEditor from "@/ee/template/pages/template-editor";
|
|||||||
import FavoritesPage from "@/pages/favorites/favorites-page";
|
import FavoritesPage from "@/pages/favorites/favorites-page";
|
||||||
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
||||||
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
||||||
|
import ConfluenceImportPage from "@/ee/confluence-import/pages/confluence-import.tsx";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -123,6 +124,10 @@ export default function App() {
|
|||||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||||
<Route path={"audit"} element={<AuditLogs />} />
|
<Route path={"audit"} element={<AuditLogs />} />
|
||||||
<Route path={"verifications"} element={<VerifiedPages />} />
|
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||||
|
<Route
|
||||||
|
path={"import/confluence"}
|
||||||
|
element={<ConfluenceImportPage />}
|
||||||
|
/>
|
||||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
|
IconFileImport,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
@@ -124,6 +125,13 @@ const groupedData: DataGroup[] = [
|
|||||||
role: "owner",
|
role: "owner",
|
||||||
env: "selfhosted",
|
env: "selfhosted",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Import",
|
||||||
|
icon: IconFileImport,
|
||||||
|
path: "/settings/import/confluence",
|
||||||
|
feature: Feature.CONFLUENCE_IMPORT,
|
||||||
|
role: "admin",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Badge color="gray" variant="light" leftSection={<IconX size={12} />}>
|
||||||
|
Cancelled
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "processing") {
|
||||||
|
return (
|
||||||
|
<Badge color="blue" variant="light" leftSection={<Loader size={10} />}>
|
||||||
|
Running
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "success") {
|
||||||
|
return (
|
||||||
|
<Badge color="teal" variant="light" leftSection={<IconCheck size={12} />}>
|
||||||
|
Completed
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconAlertCircle size={12} />}
|
||||||
|
>
|
||||||
|
Failed
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Progress value={value} color={color} size="xs" animated={item.status === "processing"} />
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{item.importedPages}/{item.totalPages || "?"} pages
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
· {item.importedSpaces}/{item.totalSpaces || "?"} spaces
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Table.Tr key={i}>
|
||||||
|
<Table.Td>
|
||||||
|
<Skeleton height={14} width={120} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Skeleton height={14} width={180} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Skeleton height={14} width={80} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Skeleton height={14} width={140} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Skeleton height={14} width={120} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Skeleton height={14} width={120} />
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfluenceImportHistory() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading } = useConfluenceImportsQuery();
|
||||||
|
|
||||||
|
const items = useMemo(() => data?.items ?? [], [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.ScrollContainer minWidth={720}>
|
||||||
|
<Table verticalSpacing="xs" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>{t("Status")}</Table.Th>
|
||||||
|
<Table.Th>{t("Site")}</Table.Th>
|
||||||
|
<Table.Th>{t("Phase")}</Table.Th>
|
||||||
|
<Table.Th>{t("Progress")}</Table.Th>
|
||||||
|
<Table.Th>{t("Started by")}</Table.Th>
|
||||||
|
<Table.Th>{t("Started at")}</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
|
||||||
|
<Table.Tbody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableSkeleton />
|
||||||
|
) : items.length > 0 ? (
|
||||||
|
items.map((item) => (
|
||||||
|
<Table.Tr key={item.fileTaskId}>
|
||||||
|
<Table.Td>
|
||||||
|
{statusBadge(item.status, item.cancelled)}
|
||||||
|
{item.status === "failed" && item.errorMessage && (
|
||||||
|
<Tooltip label={item.errorMessage} multiline w={320}>
|
||||||
|
<Text fz="xs" c="red" lineClamp={1} maw={180}>
|
||||||
|
{item.errorMessage}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" lineClamp={1} maw={240}>
|
||||||
|
{item.siteUrl}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm">{phaseLabel(item.currentPhase)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<ProgressCell item={item} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{item.creatorName ? (
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<CustomAvatar
|
||||||
|
avatarUrl={item.creatorAvatarUrl}
|
||||||
|
name={item.creatorName}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
<Text fz="sm" lineClamp={1}>
|
||||||
|
{item.creatorName}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formattedDate(new Date(item.createdAt))}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<NoTableResults colSpan={6} />
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [spaces, setSpaces] = useState<ConfluenceSpaceSummary[]>([]);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||||
|
const [importAll, setImportAll] = useState(true);
|
||||||
|
|
||||||
|
const form = useForm<CredentialsFormValues>({
|
||||||
|
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: <IconCheck size={18} />,
|
||||||
|
autoClose: 4000,
|
||||||
|
});
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(
|
||||||
|
err?.response?.data?.message || err?.message || t("Unexpected error"),
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelFlow = async () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const editionSegment = (
|
||||||
|
<SegmentedControl
|
||||||
|
value={form.values.edition}
|
||||||
|
onChange={(val) => handleEditionChange(val as ConfluenceEditionChoice)}
|
||||||
|
data={[
|
||||||
|
{ value: "server", label: t("Data Center / Server") },
|
||||||
|
{ value: "cloud", label: t("Cloud") },
|
||||||
|
]}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const authTypeSegment = form.values.edition === "server" && (
|
||||||
|
<SegmentedControl
|
||||||
|
value={form.values.authType}
|
||||||
|
onChange={(val) =>
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("Import from Confluence")}
|
||||||
|
size={720}
|
||||||
|
centered
|
||||||
|
closeOnClickOutside={!loading}
|
||||||
|
closeOnEscape={!loading}
|
||||||
|
>
|
||||||
|
<Stepper active={active} size="sm" mb="md" allowNextStepsSelect={false}>
|
||||||
|
<Stepper.Step
|
||||||
|
label={t("Connect")}
|
||||||
|
description={t("Credentials")}
|
||||||
|
icon={<IconPlug size={18} />}
|
||||||
|
/>
|
||||||
|
<Stepper.Step
|
||||||
|
label={t("Select spaces")}
|
||||||
|
description={t("Choose what to import")}
|
||||||
|
icon={<IconCloudCheck size={18} />}
|
||||||
|
/>
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
|
{active === 0 && (
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"Enter your Confluence URL and credentials. We'll validate the connection before continuing.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
{editionSegment}
|
||||||
|
{authTypeSegment}
|
||||||
|
<TextInput
|
||||||
|
label={t("Site URL")}
|
||||||
|
placeholder={
|
||||||
|
form.values.edition === "cloud"
|
||||||
|
? "https://your-site.atlassian.net/wiki"
|
||||||
|
: "https://confluence.example.com"
|
||||||
|
}
|
||||||
|
required
|
||||||
|
{...form.getInputProps("siteUrl")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.values.edition === "cloud" && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label={t("Email")}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
required
|
||||||
|
{...form.getInputProps("email")}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label={t("API token")}
|
||||||
|
description={t(
|
||||||
|
"Create at id.atlassian.com/manage-profile/security/api-tokens",
|
||||||
|
)}
|
||||||
|
required
|
||||||
|
{...form.getInputProps("token")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.values.edition === "server" &&
|
||||||
|
form.values.authType === "pat" && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label={t("Email")}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
{...form.getInputProps("email")}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label={t("Personal Access Token")}
|
||||||
|
required
|
||||||
|
{...form.getInputProps("token")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.values.edition === "server" &&
|
||||||
|
form.values.authType === "basic" && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label={t("Username")}
|
||||||
|
required
|
||||||
|
{...form.getInputProps("username")}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label={t("Password")}
|
||||||
|
required
|
||||||
|
{...form.getInputProps("password")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={t("Email (optional)")}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
{...form.getInputProps("email")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert color="red" icon={<IconAlertCircle size={18} />}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={handleCancelFlow} disabled={loading}>
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleNextFromCredentials}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{t("Test & continue")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 1 && (
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"Choose the spaces to import. Users, groups and permissions will be imported for the selected spaces.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
label={t("Import all spaces ({{count}})", {
|
||||||
|
count: spaces.length,
|
||||||
|
})}
|
||||||
|
checked={importAll}
|
||||||
|
onChange={(e) => toggleAll(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollArea h={320} type="auto" offsetScrollbars>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{spaces.map((space) => (
|
||||||
|
<Checkbox
|
||||||
|
key={space.id}
|
||||||
|
label={
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text fw={500}>{space.name}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
({space.key})
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
checked={importAll || selectedKeys.includes(space.key)}
|
||||||
|
disabled={importAll}
|
||||||
|
onChange={(e) =>
|
||||||
|
toggleSpace(space.key, e.currentTarget.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{spaces.length === 0 && (
|
||||||
|
<Text c="dimmed" ta="center" py="lg">
|
||||||
|
{t("No spaces found for this account.")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert color="red" icon={<IconAlertCircle size={18} />}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("{{count}} selected", { count: selectedCount })}
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => setActive(0)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t("Back")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleStartImport}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!importAll && selectedKeys.length === 0}
|
||||||
|
>
|
||||||
|
{t("Start import")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>
|
||||||
|
{t("Import from Confluence")} - {getAppName()}
|
||||||
|
</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<SettingsTitle title={t("Import from Confluence")} />
|
||||||
|
|
||||||
|
<Paper withBorder p="lg" radius="md" mb="lg">
|
||||||
|
<Group align="flex-start" justify="space-between" wrap="nowrap">
|
||||||
|
<Group align="flex-start" wrap="nowrap">
|
||||||
|
<ConfluenceIcon size={32} />
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text fw={600}>{t("Confluence API import")}</Text>
|
||||||
|
<Text size="sm" c="dimmed" maw={560}>
|
||||||
|
{t(
|
||||||
|
"Connect to Confluence Cloud or Data Center to import spaces, pages, attachments, comments, users, groups and permissions directly via the API.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Tooltip label={upgradeLabel} disabled={hasConfluenceImport}>
|
||||||
|
<Button onClick={open} disabled={!hasConfluenceImport}>
|
||||||
|
{t("Start import")}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Divider my="md" label={t("Import history")} labelPosition="left" />
|
||||||
|
|
||||||
|
<ConfluenceImportHistory />
|
||||||
|
|
||||||
|
<ConfluenceImportModal opened={opened} onClose={close} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<TestConnectionResponse> {
|
||||||
|
const req = await api.post<TestConnectionResponse>(
|
||||||
|
"/confluence-import/test-connection",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listConfluenceSpaces(
|
||||||
|
data: ConfluenceCredentials,
|
||||||
|
): Promise<ListSpacesResponse> {
|
||||||
|
const req = await api.post<ListSpacesResponse>(
|
||||||
|
"/confluence-import/spaces",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startConfluenceImport(
|
||||||
|
data: ConfluenceCredentials & { spaceKeys?: string[] },
|
||||||
|
): Promise<StartImportResponse> {
|
||||||
|
const req = await api.post<StartImportResponse>(
|
||||||
|
"/confluence-import/start",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfluenceImportStatus(
|
||||||
|
fileTaskId: string,
|
||||||
|
): Promise<ImportStatusResponse> {
|
||||||
|
const req = await api.post<ImportStatusResponse>(
|
||||||
|
"/confluence-import/status",
|
||||||
|
{ fileTaskId },
|
||||||
|
);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listConfluenceImports(): Promise<ListImportsResponse> {
|
||||||
|
const req = await api.post<ListImportsResponse>("/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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
};
|
||||||
@@ -55,6 +55,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
allowedHosts: ['docmost.nz'],
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: APP_URL,
|
target: APP_URL,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface ConfluenceApiImports {
|
|||||||
warnings: Generated<Json>;
|
warnings: Generated<Json>;
|
||||||
currentPhase: string | null;
|
currentPhase: string | null;
|
||||||
cancelled: Generated<boolean>;
|
cancelled: Generated<boolean>;
|
||||||
|
spaceKeys: Generated<Json>;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
creatorId: string | null;
|
creatorId: string | null;
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 7d1b155d5f...ae15159b8d
@@ -28,6 +28,9 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
case QueueJob.IMPORT_TASK:
|
case QueueJob.IMPORT_TASK:
|
||||||
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
|
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
|
||||||
break;
|
break;
|
||||||
|
case QueueJob.CONFLUENCE_API_IMPORT:
|
||||||
|
await this.processConfluenceApiImport(job.data.fileTaskId);
|
||||||
|
break;
|
||||||
case QueueJob.PDF_EXPORT_TASK:
|
case QueueJob.PDF_EXPORT_TASK:
|
||||||
await this.processExportTask(job.data.fileTaskId);
|
await this.processExportTask(job.data.fileTaskId);
|
||||||
break;
|
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<void> {
|
||||||
|
const service = this.getConfluenceApiImportService();
|
||||||
|
await service.processImport(fileTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
private async processExportTask(fileTaskId: string): Promise<void> {
|
private async processExportTask(fileTaskId: string): Promise<void> {
|
||||||
const pdfExportService = this.getPdfExportService();
|
const pdfExportService = this.getPdfExportService();
|
||||||
await pdfExportService.generateAndStorePdf(fileTaskId);
|
await pdfExportService.generateAndStorePdf(fileTaskId);
|
||||||
@@ -74,6 +90,8 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
await this.handleFailedImportJob(job);
|
await this.handleFailedImportJob(job);
|
||||||
} else if (job.name === QueueJob.PDF_EXPORT_TASK) {
|
} else if (job.name === QueueJob.PDF_EXPORT_TASK) {
|
||||||
await this.handleFailedExportJob(job);
|
await this.handleFailedExportJob(job);
|
||||||
|
} else if (job.name === QueueJob.CONFLUENCE_API_IMPORT) {
|
||||||
|
await this.handleFailedExportJob(job);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export enum QueueJob {
|
|||||||
FIRST_PAYMENT_EMAIL = 'first-payment-email',
|
FIRST_PAYMENT_EMAIL = 'first-payment-email',
|
||||||
|
|
||||||
IMPORT_TASK = 'import-task',
|
IMPORT_TASK = 'import-task',
|
||||||
|
CONFLUENCE_API_IMPORT = 'confluence-api-import-task',
|
||||||
EXPORT_TASK = 'export-task',
|
EXPORT_TASK = 'export-task',
|
||||||
|
|
||||||
SEARCH_INDEX_PAGE = 'search-index-page',
|
SEARCH_INDEX_PAGE = 'search-index-page',
|
||||||
|
|||||||
Reference in New Issue
Block a user