Compare commits

..
2 Commits
73 changed files with 224 additions and 2588 deletions
@@ -22,7 +22,6 @@
"Can view": "Can view",
"Can view pages in space but not edit.": "Can view pages in space but not edit.",
"Cancel": "Cancel",
"Cancelled": "Cancelled",
"Change email": "Change email",
"Change password": "Change password",
"Change photo": "Change photo",
@@ -30,9 +29,7 @@
"Choose your preferred color scheme.": "Choose your preferred color scheme.",
"Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.",
"Completed": "Completed",
"Confirm": "Confirm",
"Confluence site": "Confluence site",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link",
"Create": "Create",
@@ -60,10 +57,6 @@
"e.g Space for product team": "e.g. Space for product team",
"e.g Space for sales team to collaborate": "e.g. Space for sales team to collaborate",
"Edit": "Edit",
"Everyone with access to this space": "Everyone with access to this space",
"Failed": "Failed",
"Import details": "Import details",
"Permissions": "Permissions",
"Read": "Read",
"Edit group": "Edit group",
"Email": "Email",
@@ -83,7 +76,6 @@
"Failed to restore page": "Failed to restore page",
"Failed to fetch recent pages": "Failed to fetch recent pages",
"Failed to import pages": "Failed to import pages",
"Failed to load comments. An error occurred.": "Failed to load comments. An error occurred.",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
"Failed to update data": "Failed to update data",
"Failed to create base": "Failed to create base",
@@ -158,9 +150,6 @@
"page": "page",
"Page deleted successfully": "Page deleted successfully",
"Page history": "Page history",
"Restricted pages": "Restricted pages",
"Restrictions": "Restrictions",
"Running": "Running",
"Select version": "Select version",
"Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
@@ -197,7 +186,6 @@
"Setup workspace": "Setup workspace",
"Sign In": "Sign In",
"Sign Up": "Sign Up",
"Site": "Site",
"Slug": "Slug",
"Space": "Space",
"Space description": "Space description",
@@ -206,13 +194,10 @@
"Space settings": "Space settings",
"Space slug": "Space slug",
"Spaces": "Spaces",
"spaces": "spaces",
"Spaces you belong to": "Spaces you belong to",
"No space found": "No space found",
"Search for spaces": "Search for spaces",
"Start typing to search...": "Start typing to search...",
"Started at": "Started at",
"Started by": "Started by",
"Status": "Status",
"Successfully imported": "Successfully imported",
"Successfully restored": "Successfully restored",
@@ -226,8 +211,6 @@
"Untitled": "Untitled",
"Updated successfully": "Updated successfully",
"User": "User",
"Users": "Users",
"users": "users",
"Workspace": "Workspace",
"Workspace Name": "Workspace Name",
"Workspace settings": "Workspace settings",
+1 -7
View File
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
import { useTrackOrigin } from "@/hooks/use-track-origin";
const SetupWorkspace = lazy(() => import("@/pages/auth/setup-workspace.tsx"));
const LoginPage = lazy(() => import("@/pages/auth/login"));
const Home = lazy(() => import("@/pages/dashboard/home"));
@@ -83,9 +84,6 @@ const AiChat = lazy(() => import("@/ee/ai-chat/pages/ai-chat.tsx"));
const VerifyEmail = lazy(() => import("@/ee/pages/verify-email.tsx"));
const LabelPage = lazy(() => import("@/pages/label/label-page"));
const OAuthConsent = lazy(() => import("@/ee/oauth/pages/oauth-consent.tsx"));
const ConfluenceImportPage = lazy(
() => import("@/ee/confluence-import/pages/confluence-import.tsx"),
);
export default function App() {
const { t } = useTranslation();
@@ -192,10 +190,6 @@ export default function App() {
element={<Navigate to="/settings/audit/siem" replace />}
/>
<Route path={"verifications"} element={<VerifiedPages />} />
<Route
path={"import/confluence"}
element={<ConfluenceImportPage />}
/>
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route>
@@ -1,6 +1,5 @@
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import { CommentErrorBoundary } from "@/features/comment/components/comment-error-boundary.tsx";
import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { lazy, ReactNode, Suspense, useEffect } from "react";
@@ -42,11 +41,7 @@ export default function Aside() {
switch (tab) {
case "comments":
component = (
<CommentErrorBoundary>
<CommentListWithTabs />
</CommentErrorBoundary>
);
component = <CommentListWithTabs />;
title = "Comments";
break;
case "toc":
@@ -15,12 +15,11 @@ import {
IconSparkles,
IconHistory,
IconShieldCheck,
IconFileImport,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
import { useTranslation } from "react-i18next";
import { isBetaConfluenceImporter, isCloud } from "@/lib/config.ts";
import { isCloud } from "@/lib/config.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
@@ -52,7 +51,6 @@ type DataItem = {
feature?: string;
role?: "admin" | "owner";
env?: "cloud" | "selfhosted";
show?: () => boolean;
};
type DataGroup = {
@@ -127,15 +125,6 @@ const groupedData: DataGroup[] = [
role: "owner",
env: "selfhosted",
},
{
label: "Import",
icon: IconFileImport,
path: "/settings/import/confluence",
feature: Feature.CONFLUENCE_API_IMPORT,
role: "admin",
env: "selfhosted",
show: () => isBetaConfluenceImporter(),
},
],
},
{
@@ -169,7 +158,6 @@ export default function SettingsSidebar() {
entitlements?.features?.includes(f) ?? false;
const canShowItem = (item: DataItem) => {
if (item.show && !item.show()) return false;
if (item.env === "cloud" && !isCloud()) return false;
if (item.env === "selfhosted" && isCloud()) return false;
if (item.role === "admin" && !isAdmin) return false;
@@ -1,340 +0,0 @@
import { useMemo, useState } from "react";
import {
Badge,
Group,
Loader,
Modal,
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";
const BADGE_STYLES = {
root: { flexShrink: 0 },
label: { overflow: "visible" as const },
};
function statusBadge(
status: ConfluenceImportStatus,
cancelled: boolean,
t: (key: string) => string,
) {
if (cancelled) {
return (
<Badge
color="gray"
variant="light"
leftSection={<IconX size={12} />}
styles={BADGE_STYLES}
>
{t("Cancelled")}
</Badge>
);
}
if (status === "processing") {
return (
<Badge
color="blue"
variant="light"
leftSection={<Loader size={10} />}
styles={BADGE_STYLES}
>
{t("Running")}
</Badge>
);
}
if (status === "success") {
return (
<Badge
color="teal"
variant="light"
leftSection={<IconCheck size={12} />}
styles={BADGE_STYLES}
>
{t("Completed")}
</Badge>
);
}
return (
<Badge
color="red"
variant="light"
leftSection={<IconAlertCircle size={12} />}
styles={BADGE_STYLES}
>
{t("Failed")}
</Badge>
);
}
function phaseLabel(phase: string | null, t: (key: string) => string): string {
if (!phase) return "—";
return t(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 { t } = useTranslation();
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 || "?"} {t("pages")}
</Text>
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
· {item.importedSpaces}/{item.totalSpaces || "?"} {t("spaces")}
</Text>
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
· {item.importedUsers}/{item.totalUsers || "?"} {t("users")}
</Text>
</Group>
</Stack>
);
}
function ImportStatsModal({
item,
onClose,
}: {
item: ConfluenceImportHistoryItem | null;
onClose: () => void;
}) {
const { t } = useTranslation();
const stats = item
? [
{
label: t("Spaces"),
imported: item.importedSpaces,
total: item.totalSpaces,
},
{
label: t("Pages"),
imported: item.importedPages,
total: item.totalPages,
},
{
label: t("Users"),
imported: item.importedUsers,
total: item.totalUsers,
},
{
label: t("Groups"),
imported: item.importedGroups,
total: item.totalGroups,
},
{
label: t("Attachments"),
imported: item.importedAttachments,
total: item.totalAttachments,
},
{
label: t("Labels"),
imported: item.importedLabels,
total: item.totalLabels,
},
{
label: t("Restricted pages"),
imported: item.importedRestrictedPages,
total: item.totalRestrictedPages,
},
]
: [];
return (
<Modal
opened={!!item}
onClose={onClose}
title={t("Import details")}
size="md"
>
{item && (
<Stack gap="sm">
<div>
<Text fz="sm" c="dimmed">
{t("Confluence site")}
</Text>
<Text fz="sm" fw={500} lineClamp={1}>
{item.siteUrl}
</Text>
</div>
<div>
<Text fz="sm" c="dimmed">
{t("Started at")}
</Text>
<Text fz="sm">{formattedDate(new Date(item.createdAt))}</Text>
</div>
<Table verticalSpacing="xs" fz="sm">
<Table.Tbody>
{stats.map((stat) => (
<Table.Tr key={stat.label}>
<Table.Td>
<Text fz="sm">{stat.label}</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" ta="right">
{stat.imported} / {stat.total}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
)}
</Modal>
);
}
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 [selectedItem, setSelectedItem] =
useState<ConfluenceImportHistoryItem | null>(null);
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}
onClick={() => setSelectedItem(item)}
style={{ cursor: "pointer" }}
>
<Table.Td>
{statusBadge(item.status, item.cancelled, t)}
{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, t)}</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>
<ImportStatsModal
item={selectedItem}
onClose={() => setSelectedItem(null)}
/>
</Table.ScrollContainer>
);
}
@@ -1,445 +0,0 @@
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 ((await 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(
"Pages, comments, page labels, users, groups, spaces and permissions will be imported.",
)}
</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>
);
}
@@ -1,67 +0,0 @@
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_API_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} />
</>
);
}
@@ -1,17 +0,0 @@
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;
},
});
}
@@ -1,64 +0,0 @@
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/history");
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;
}
@@ -1,92 +0,0 @@
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;
totalGroups?: number;
importedGroups?: number;
totalRestrictedPages?: number;
importedRestrictedPages?: number;
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;
totalGroups: number;
importedGroups: number;
totalAttachments: number;
importedAttachments: number;
totalLabels: number;
importedLabels: number;
totalRestrictedPages: number;
importedRestrictedPages: number;
cancelled: boolean;
spaceKeys: string[];
createdAt: string;
updatedAt: string;
creatorId: string | null;
creatorName: string | null;
creatorAvatarUrl: string | null;
};
export type ListImportsResponse = {
items: ConfluenceImportHistoryItem[];
};
-1
View File
@@ -7,7 +7,6 @@ export const Feature = {
PAGE_PERMISSIONS: 'page:permissions',
AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence',
CONFLUENCE_API_IMPORT: 'import:confluence-api',
DOCX_IMPORT: 'import:docx',
PDF_IMPORT: 'import:pdf',
ATTACHMENT_INDEXING: 'attachment:indexing',
@@ -53,14 +53,7 @@ export function PagePermissionItem({
{isCurrentUser && <Text span c="dimmed"> ({t("You")})</Text>}
</AutoTooltipText>
<AutoTooltipText fz="xs" c="dimmed">
{member.type === "user"
? member.email
: member.isDefault
? // Page access still requires space membership, so the
// workspace-wide member count would overstate who can
// actually see the page.
t("Everyone with access to this space")
: formatMemberCount(member.memberCount, t)}
{member.type === "user" ? member.email : formatMemberCount(member.memberCount, t)}
</AutoTooltipText>
</div>
</div>
@@ -166,18 +166,6 @@ export default function useAuth() {
const handleLogout = async () => {
setCurrentUser(RESET);
await logout();
try {
if (typeof indexedDB?.databases === "function") {
const dbs = await indexedDB.databases();
dbs
.filter((db) => db.name?.startsWith("page."))
.forEach((db) => indexedDB.deleteDatabase(db.name!));
}
} catch {
//
}
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
};
@@ -11,7 +11,6 @@ import {
} from "@/features/comment/atoms/comment-atom";
import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
import { CommentErrorBoundary } from "@/features/comment/components/comment-error-boundary";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { useCreateCommentMutation } from "@/features/comment/queries/comment-query";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
@@ -171,16 +170,14 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
</div>
</Group>
<CommentErrorBoundary>
<CommentEditor
onUpdate={handleCommentEditorChange}
onSave={handleAddComment}
placeholder={t("Write a comment")}
editable={true}
autofocus={true}
/>
<CommentActions onSave={handleAddComment} isLoading={isPending} />
</CommentErrorBoundary>
<CommentEditor
onUpdate={handleCommentEditorChange}
onSave={handleAddComment}
placeholder={t("Write a comment")}
editable={true}
autofocus={true}
/>
<CommentActions onSave={handleAddComment} isLoading={isPending} />
</Stack>
</Dialog>
);
@@ -1,8 +1,7 @@
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
import { Placeholder } from "@tiptap/extension-placeholder";
import { StarterKit } from "@tiptap/starter-kit";
import { TextStyle } from "@tiptap/extension-text-style";
import { Mention, LinkExtension, Color } from "@docmost/editor-ext";
import { Mention, LinkExtension } from "@docmost/editor-ext";
import classes from "./comment.module.css";
import { useFocusWithin } from "@mantine/hooks";
import clsx from "clsx";
@@ -12,8 +11,6 @@ import EmojiCommand from "@/features/editor/extensions/emoji-command";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
import MentionView from "@/features/editor/components/mention/mention-view";
import { platformModifierKey } from "@/lib";
import { TableKit } from "@tiptap/extension-table";
import { TaskList, TaskItem } from "@tiptap/extension-list";
interface CommentEditorProps {
defaultContent?: any;
@@ -52,8 +49,6 @@ const CommentEditor = forwardRef(
placeholder: placeholder || t("Reply..."),
}),
LinkExtension,
TextStyle,
Color,
EmojiCommand,
Mention.configure({
suggestion: {
@@ -71,11 +66,6 @@ const CommentEditor = forwardRef(
return ReactNodeViewRenderer(MentionView);
},
}),
TableKit,
TaskList,
TaskItem.configure({
nested: true,
}),
],
editorProps: {
attributes: {
@@ -123,12 +113,7 @@ const CommentEditor = forwardRef(
// websocket on another browser). Skip for editable editors to avoid
// resetting the cursor position on every keystroke.
useEffect(() => {
if (
!editable &&
commentEditor &&
!commentEditor.isDestroyed &&
defaultContent
) {
if (!editable && commentEditor && !commentEditor.isDestroyed && defaultContent) {
commentEditor.commands.setContent(defaultContent);
}
}, [defaultContent, editable, commentEditor]);
@@ -1,38 +0,0 @@
import { ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Button } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { EmptyState } from "@/components/ui/empty-state.tsx";
type CommentErrorBoundaryProps = {
children: ReactNode;
};
// Contain comment-editor render throws (e.g. schema errors) so they don't unmount the whole app.
export function CommentErrorBoundary({ children }: CommentErrorBoundaryProps) {
const { t } = useTranslation();
return (
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) => (
<EmptyState
icon={IconAlertTriangle}
title={t("Failed to load comments. An error occurred.")}
action={
<Button
variant="default"
size="sm"
mt="xs"
onClick={resetErrorBoundary}
>
{t("Try again")}
</Button>
}
/>
)}
>
{children}
</ErrorBoundary>
);
}
@@ -53,10 +53,6 @@
margin-block-end: 0;
}
.ProseMirror :global(.tableWrapper) table {
min-width: 100% !important;
}
.actions {
}
@@ -247,16 +247,6 @@ export default function LinkView(props: MarkViewProps) {
const handleNavigate = useCallback(() => {
if (!href) return;
if (href.startsWith("#")) {
const anchor = href.slice(1);
const element =
document.querySelector(`[id="${anchor}"]`) ||
document.querySelector(`[data-id="${anchor}"]`);
element?.scrollIntoView({ behavior: "smooth", block: "start" });
navigate(`${location.pathname}#${anchor}`, { replace: true });
return;
}
if (isInternal) {
let targetPath = href;
let anchor = "";
@@ -17,7 +17,6 @@ export default function MentionView(props: NodeViewProps) {
const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
const isPageMention = entityType === "page";
const hasTarget = isPageMention && !!slugId;
const { spaceSlug, pageSlug } = useParams();
const { shareId } = useParams();
const navigate = useNavigate();
@@ -79,22 +78,7 @@ export default function MentionView(props: NodeViewProps) {
</Text>
)}
{isPageMention && !hasTarget && (
<Text component="span" fw={500} className={classes.pageMentionLink}>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>{label}</span>
</Text>
)}
{hasTarget && isShareRoute && (
{isPageMention && isShareRoute && (
<Anchor
component={Link}
fw={500}
@@ -118,7 +102,7 @@ export default function MentionView(props: NodeViewProps) {
</Anchor>
)}
{hasTarget && isPublicSpaceRoute && publicPageData?.page && (
{isPageMention && isPublicSpaceRoute && publicPageData?.page && (
<Anchor
component={Link}
fw={500}
@@ -150,7 +134,7 @@ export default function MentionView(props: NodeViewProps) {
{/* No public URL: the /p/ resolver redirects members to the page and
funnels anonymous visitors through login first. New tab, so the
redirect chain never rewrites the docs tab's history. */}
{hasTarget && isPublicSpaceRoute && !publicPageData?.page && (
{isPageMention && isPublicSpaceRoute && !publicPageData?.page && (
<Anchor
fw={500}
href={buildPageUrl(undefined, slugId, label, anchorId)}
@@ -172,7 +156,7 @@ export default function MentionView(props: NodeViewProps) {
</Anchor>
)}
{hasTarget && !isShareRoute && !isPublicSpaceRoute && isError && (
{isPageMention && !isShareRoute && !isPublicSpaceRoute && isError && (
<Anchor
component={Link}
fw={500}
@@ -196,7 +180,7 @@ export default function MentionView(props: NodeViewProps) {
</Anchor>
)}
{hasTarget && !isShareRoute && !isPublicSpaceRoute && !isError && (
{isPageMention && !isShareRoute && !isPublicSpaceRoute && !isError && (
<Anchor
component={Link}
fw={500}
@@ -66,7 +66,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
}
// Clear search term in editor
if (isEditorReady(editor)) {
editor.commands.setSearchTerms([""]);
editor.commands.setSearchTerm("");
}
};
@@ -117,7 +117,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
useEffect(() => {
if (!isEditorReady(editor)) return;
editor.commands.setSearchTerms([searchText]);
editor.commands.setSearchTerm(searchText);
editor.commands.resetIndex();
editor.commands.selectCurrentItem();
}, [searchText]);
@@ -181,10 +181,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
const location = useLocation();
useEffect(() => {
if (pageFindState.isOpen) {
closeDialog();
}
}, [location.pathname]);
closeDialog();
}, [location]);
return (
<Dialog
@@ -1,205 +0,0 @@
import { ActionIcon, Dialog, Flex, Text, Tooltip } from "@mantine/core";
import {
IconArrowNarrowDown,
IconArrowNarrowUp,
IconX,
} from "@tabler/icons-react";
import { useEditor } from "@tiptap/react";
import { isEditorReady } from "@docmost/editor-ext";
import React, { useCallback, useEffect, useRef, useState } from "react";
import classes from "./search-replace.module.css";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
interface SearchNavigationDialogProps {
editor: ReturnType<typeof useEditor>;
}
interface SearchNavigationEvent extends CustomEvent {
detail: {
searchTerms: string[];
wholeWord?: boolean;
};
}
function SearchNavigationDialog({ editor }: SearchNavigationDialogProps) {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const openRef = useRef(false);
const [resultState, setResultState] = useState({
resultIndex: 0,
resultsLength: 0,
});
const goToSelection = () => {
if (!isEditorReady(editor)) return;
const { results, resultIndex } = editor.storage.searchAndReplace;
const position = results[resultIndex];
setResultState({
resultsLength: results.length,
resultIndex,
});
if (!position) return;
requestAnimationFrame(() => {
document
.querySelector(".search-result-current")
?.scrollIntoView({ behavior: "smooth", block: "center" });
});
};
const next = () => {
if (!isEditorReady(editor)) return;
editor.commands.nextSearchResult();
goToSelection();
};
const previous = () => {
if (!isEditorReady(editor)) return;
editor.commands.previousSearchResult();
goToSelection();
};
const close = useCallback(() => {
if (!openRef.current) return;
openRef.current = false;
setOpen(false);
if (isEditorReady(editor)) {
editor.commands.setSearchTerms([""]);
}
const nextParams = new URLSearchParams(location.search);
nextParams.delete("q");
nextParams.delete("m");
const nextSearch = nextParams.toString();
navigate(
{
pathname: location.pathname,
search: nextSearch ? `?${nextSearch}` : "",
hash: location.hash,
},
{ replace: true },
);
}, [editor, location.hash, location.pathname, location.search, navigate]);
useEffect(() => {
const handleOpen = (event: Event) => {
const { searchTerms: terms, wholeWord = true } = (
event as SearchNavigationEvent
).detail;
if (!terms?.length || !isEditorReady(editor)) return;
openRef.current = false;
editor.commands.setSearchTerms(terms);
editor.commands.setWholeWord(wholeWord);
editor.commands.resetIndex();
const { results, resultIndex } = editor.storage.searchAndReplace;
openRef.current = true;
if (results.length === 0) {
close();
return;
}
setOpen(true);
setResultState({
resultIndex,
resultsLength: results.length,
});
goToSelection();
};
const handleClose = () => {
if (openRef.current) {
close();
}
};
document.addEventListener("openSearchNavigationDialog", handleOpen);
document.addEventListener("openFindDialogFromEditor", handleClose);
document.addEventListener("closeFindDialogFromEditor", handleClose);
return () => {
document.removeEventListener("openSearchNavigationDialog", handleOpen);
document.removeEventListener("openFindDialogFromEditor", handleClose);
document.removeEventListener("closeFindDialogFromEditor", handleClose);
};
}, [close, editor]);
useEffect(() => {
const handleTransaction = () => {
if (!openRef.current || editor.isDestroyed) return;
const { results } = editor.storage.searchAndReplace;
if (results.length === 0) {
close();
}
};
editor.on("transaction", handleTransaction);
return () => {
editor.off("transaction", handleTransaction);
};
}, [close, editor]);
return (
<Dialog
className={classes.findDialog}
opened={open}
size="xs"
radius="md"
w="auto"
position={{ top: 90, right: 50 }}
withBorder
aria-label="Search navigation"
>
<Flex align="center" gap="xs">
<Text size="xs" style={{ flex: 1 }}>
{resultState.resultsLength > 0
? `${resultState.resultIndex + 1}/${resultState.resultsLength}`
: t("Not found")}
</Text>
<Tooltip label="Previous match">
<ActionIcon
variant="subtle"
color="gray"
onClick={previous}
aria-label="Previous match"
disabled={resultState.resultsLength === 0}
>
<IconArrowNarrowUp size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Next match">
<ActionIcon
variant="subtle"
color="gray"
onClick={next}
aria-label="Next match"
disabled={resultState.resultsLength === 0}
>
<IconArrowNarrowDown size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Close">
<ActionIcon
variant="subtle"
color="gray"
onClick={close}
aria-label="Close"
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
</Flex>
</Dialog>
);
}
export default SearchNavigationDialog;
@@ -1,47 +0,0 @@
import { useEffect, useRef } from "react";
import type { useEditor } from "@tiptap/react";
interface UseSearchNavigationParamsProps {
editor: ReturnType<typeof useEditor>;
isSynced: boolean;
pageId: string;
searchParams: URLSearchParams;
showStatic: boolean;
}
export function useSearchNavigationParams({
editor,
isSynced,
pageId,
searchParams,
showStatic,
}: UseSearchNavigationParamsProps) {
const appliedSearchKeyRef = useRef<string | null>(null);
const searchKey = `${pageId}:${searchParams.toString()}`;
useEffect(() => {
const searchQueries = searchParams.getAll("q");
if (!searchQueries.length) {
appliedSearchKeyRef.current = null;
return;
}
if (
!editor ||
editor.isDestroyed ||
!editor.view.dom.isConnected ||
appliedSearchKeyRef.current === searchKey
) {
return;
}
const match = searchParams.get("m");
appliedSearchKeyRef.current = searchKey;
document.dispatchEvent(
new CustomEvent("openSearchNavigationDialog", {
detail: { searchTerms: searchQueries, wholeWord: match === "whole" },
}),
);
}, [editor, isSynced, searchKey, searchParams, showStatic]);
}
@@ -1,112 +0,0 @@
import { Editor, Node } from "@tiptap/core";
import { GapCursor } from "@tiptap/pm/gapcursor";
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
import { StarterKit } from "@tiptap/starter-kit";
import { describe, expect, it } from "vitest";
import { TiptapDocument } from "./document";
const Footnotes = Node.create({
name: "footnotes",
group: "",
content: "paragraph*",
isolating: true,
renderHTML() {
return ["ol", { class: "footnotes" }, 0];
},
});
const AtomBlock = Node.create({
name: "atomBlock",
group: "block",
atom: true,
renderHTML() {
return ["div", { "data-atom-block": "" }];
},
});
const IsolatingBlock = Node.create({
name: "isolatingBlock",
group: "block",
content: "paragraph+",
isolating: true,
renderHTML() {
return ["div", { "data-isolating-block": "" }, 0];
},
});
function createEditor(content: object[]) {
const element = document.createElement("div");
document.body.appendChild(element);
return new Editor({
element,
extensions: [
TiptapDocument,
StarterKit.configure({ document: false }),
Footnotes,
AtomBlock,
IsolatingBlock,
],
content: { type: "doc", content },
});
}
function pressKey(editor: Editor, key: string, keyCode: number) {
editor.view.dom.dispatchEvent(
new KeyboardEvent("keydown", {
key,
keyCode,
bubbles: true,
cancelable: true,
}),
);
}
describe("TiptapDocument", () => {
it("stops on the gap when arrowing down from a selected block node", () => {
const editor = createEditor([
{ type: "atomBlock" },
{ type: "atomBlock" },
{ type: "paragraph" },
]);
const gapPos = editor.state.doc.child(0).nodeSize;
editor.view.dispatch(
editor.state.tr.setSelection(
NodeSelection.create(editor.state.doc, 0),
),
);
pressKey(editor, "ArrowDown", 40);
expect(editor.state.selection).toBeInstanceOf(GapCursor);
expect(editor.state.selection.head).toBe(gapPos);
editor.destroy();
});
it("stops on the gap when arrowing right out of an isolating block", () => {
const paragraph = (text: string) => ({
type: "paragraph",
content: [{ type: "text", text }],
});
const editor = createEditor([
{ type: "isolatingBlock", content: [paragraph("a")] },
{ type: "isolatingBlock", content: [paragraph("b")] },
{ type: "paragraph" },
]);
const gapPos = editor.state.doc.child(0).nodeSize;
const endOfFirstText = gapPos - 2;
editor.view.dispatch(
editor.state.tr.setSelection(
TextSelection.create(editor.state.doc, endOfFirstText),
),
);
pressKey(editor, "ArrowRight", 39);
expect(editor.state.selection).toBeInstanceOf(GapCursor);
expect(editor.state.selection.head).toBe(gapPos);
editor.destroy();
});
});
@@ -1,8 +0,0 @@
import { Document } from "@tiptap/extension-document";
// With `block+ footnotes?`, ProseMirror's defaultType after the first block is
// `footnotes` (not a textblock), so GapCursor.valid() rejects every top-level gap.
export const TiptapDocument = Document.extend({
content: "block+ footnotes?",
allowGapCursor: true,
});
@@ -1,6 +1,6 @@
import { markInputRule } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { TiptapDocument } from "@/features/editor/extensions/document";
import { Document } from "@tiptap/extension-document";
import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list";
@@ -10,6 +10,7 @@ import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import { Youtube } from "@tiptap/extension-youtube";
import SlashCommand, {
SlashCommandExtension as Command,
@@ -53,7 +54,6 @@ import {
Subpages,
Heading,
Highlight,
Color,
Indent,
UniqueID,
SharedStorage,
@@ -148,7 +148,9 @@ export const mainExtensions = [
codeBlock: false,
code: false,
}),
TiptapDocument,
Document.extend({
content: "block+ footnotes?",
}),
// Override TipTap's Code extension to fix the inline code input rule.
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
// before the opening backtick as part of the match, causing markInputRule
@@ -65,13 +65,11 @@ import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import SearchNavigationDialog from "@/features/editor/components/search-and-replace/search-navigation-dialog.tsx";
import { useSearchNavigationParams } from "@/features/editor/components/search-and-replace/use-search-navigation-params.ts";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams, useSearchParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import { extractPageSlugId, platformModifierKey } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
@@ -201,7 +199,6 @@ function CollabPageEditor({
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const [searchParams] = useSearchParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const canScroll = useCallback(
@@ -440,14 +437,6 @@ function CollabPageEditor({
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
useSearchNavigationParams({
editor,
isSynced,
pageId,
searchParams,
showStatic,
});
useEffect(() => {
if (
!hasConnectedOnceRef.current &&
@@ -471,7 +460,6 @@ function CollabPageEditor({
{editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} />
)}
{editor && <SearchNavigationDialog editor={editor} />}
{editor && editorIsEditable && (
<div>
@@ -1,46 +1,6 @@
/* Highlight colors with dark mode support */
.ProseMirror {
@mixin dark {
/* Arbitrary (imported) colors have no hand-tuned dark variant, so derive
one: cap lightness and chroma so the fill sits on the dark surface.
Our own palette opts out and keeps the values below. */
mark[data-color]:not(
[data-color="#98d8f2" i],
[data-color="#7edb6c" i],
[data-color="#e0d6ed" i],
[data-color="#ffc6c2" i],
[data-color="#faf594" i],
[data-color="#f5c8a9" i],
[data-color="#f5cfe0" i],
[data-color="#dfdfd7" i],
[data-color="#d7c4b7" i]
) {
background-color: oklch(
from var(--mark-bg, #fff) min(l, clamp(0.28, calc(1.22 - l), 0.45))
min(calc(c * 1.8), 0.09) h
) !important;
}
/* Imported text colors are picked for a light page and go unreadable on
the dark surface, so lift their lightness. */
span[data-text-color]:not(
[data-text-color="#2563EB" i],
[data-text-color="#008A00" i],
[data-text-color="#9333EA" i],
[data-text-color="#E00000" i],
[data-text-color="#EAB308" i],
[data-text-color="#FFA500" i],
[data-text-color="#BA4081" i],
[data-text-color="#A8A29E" i],
[data-text-color="#92400E" i]
) {
color: oklch(
from var(--text-color, currentcolor) max(l, 0.72) min(c, 0.16) h
) !important;
}
}
/* Blue */
mark[data-color="#98d8f2"] {
background-color: light-dark(
@@ -86,16 +86,6 @@
.ProseMirror {
table {
@mixin dark {
/* Arbitrary (imported) colors: derive a dark variant; the hand-tuned
palette rules below override this for native colors. */
td[data-background-color],
th[data-background-color] {
background-color: oklch(
from var(--cell-bg, #fff) min(l, clamp(0.28, calc(1.22 - l), 0.45))
min(calc(c * 1.8), 0.09) h
) !important;
}
/* Blue */
td[data-background-color="#b4d5ff"],
th[data-background-color="#b4d5ff"] {
+1 -35
View File
@@ -25,37 +25,11 @@ const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
return `${titleSlug}-${pageSlugId}`;
};
function appendSearchParams(
url: string,
search?: string[],
wholeWord?: boolean,
): string {
if(search?.length === 0){
return url;
}
const params = new URLSearchParams();
search
?.map((term) => term.trim())
.filter(Boolean)
.forEach((term) => params.append("q", term));
if (wholeWord) {
params.set("m", "whole");
}
const queryString = params.toString();
return queryString ? `${url}?${queryString}` : url;
}
export const buildPageUrl = (
spaceName: string,
pageSlugId: string,
pageTitle?: string,
anchorId?: string,
search?: string[],
wholeWord?: boolean,
): string => {
let url: string;
if (spaceName === undefined) {
@@ -63,9 +37,6 @@ export const buildPageUrl = (
} else {
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}
url = appendSearchParams(url, search, wholeWord);
return anchorId ? `${url}#${anchorId}` : url;
};
@@ -74,19 +45,14 @@ export const buildSharedPageUrl = (opts: {
pageSlugId: string;
pageTitle?: string;
anchorId?: string;
search?: string[];
wholeWord?: boolean;
}): string => {
const { shareId, pageSlugId, pageTitle, anchorId, search, wholeWord } = opts;
const { shareId, pageSlugId, pageTitle, anchorId } = opts;
let url: string;
if (!shareId) {
url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} else {
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}
url = appendSearchParams(url, search, wholeWord);
return anchorId ? `${url}#${anchorId}` : url;
};
@@ -25,8 +25,6 @@ import { SearchMobileControl } from "@/features/search/components/search-control
import styles from "./docs.module.css";
const MemoizedDocsSidebarTree = React.memo(DocsSidebarTree);
const MANTINE_COLOR_SCHEME_ATTRIBUTE = "data-mantine-color-scheme";
const DOCS_PRINT_COLOR_SCHEME_ATTRIBUTE = "data-docs-print-color-scheme";
type DocsShellProps = {
surface: DocsSurface;
@@ -49,45 +47,6 @@ export default function DocsShell({
);
const [mobileTocOpen, setMobileTocOpen] = useAtom(docsMobileTocAtom);
React.useEffect(() => {
const root = document.documentElement;
let previousColorScheme: string | null = null;
let isPrinting = false;
const restoreColorScheme = () => {
if (!isPrinting) return;
if (previousColorScheme === null) {
root.removeAttribute(MANTINE_COLOR_SCHEME_ATTRIBUTE);
} else {
root.setAttribute(MANTINE_COLOR_SCHEME_ATTRIBUTE, previousColorScheme);
}
root.removeAttribute(DOCS_PRINT_COLOR_SCHEME_ATTRIBUTE);
isPrinting = false;
};
const useLightPrintTheme = () => {
if (isPrinting) return;
previousColorScheme = root.getAttribute(MANTINE_COLOR_SCHEME_ATTRIBUTE);
root.setAttribute(
DOCS_PRINT_COLOR_SCHEME_ATTRIBUTE,
previousColorScheme ?? "light",
);
root.setAttribute(MANTINE_COLOR_SCHEME_ATTRIBUTE, "light");
isPrinting = true;
};
window.addEventListener("beforeprint", useLightPrintTheme);
window.addEventListener("afterprint", restoreColorScheme);
return () => {
window.removeEventListener("beforeprint", useLightPrintTheme);
window.removeEventListener("afterprint", restoreColorScheme);
restoreColorScheme();
};
}, []);
return (
<DocsSurfaceProvider value={surface}>
<div className={clsx(styles.root, "public-typography")}>
@@ -900,59 +900,3 @@
display: flex;
flex-direction: column;
}
/* ---------- Print ---------- */
@page public-doc {
background-color: #fff;
}
/* Only the article prints; the body grid collapses so it takes the page width. */
@media print {
:global(html):has(.root),
:global(body):has(.root) {
page: public-doc;
background-color: #fff !important;
}
:global(html[data-docs-print-color-scheme="dark"])
.root.root
:global(.codeBlock svg) {
filter: invert(1) hue-rotate(180deg);
}
.header,
.sidebar,
.toc,
.articleActions,
.breadcrumbs,
.pageNav,
.footer {
display: none !important;
}
.root {
--docs-bg: #fff;
--docs-fg: #1f1f1f;
--docs-content-fg: var(--docs-fg);
--docs-nav-fg: #495057;
--docs-muted: #5f6368;
--docs-hover: #f1f3f5;
--docs-faint: #868e96;
--docs-border: #dee2e6;
--docs-header-bg: #fff;
min-height: 0;
background-color: #fff;
color: var(--docs-fg);
}
.body {
display: block;
}
.article {
max-width: none;
padding: 0;
}
}
@@ -117,9 +117,6 @@ export function SearchResultItem({
pageResult.space.slug,
pageResult.slugId,
pageResult.title,
undefined,
pageResult.matchedText,
pageResult.wholeWord
)}
style={{ userSelect: "none" }}
>
@@ -14,8 +14,6 @@ export interface IPageSearch {
updatedAt: Date;
rank: string;
highlight: string;
matchedText: string[];
wholeWord: boolean;
space: Partial<ISpace>;
}
-4
View File
@@ -51,10 +51,6 @@ export function getAiVectorDriver(): string {
return getConfigValue("AI_VECTOR_DRIVER");
}
export function isBetaConfluenceImporter(): boolean {
return castToBoolean(getConfigValue("BETA_CONFLUENCE_IMPORTER"));
}
export function getAvatarUrl(
avatarUrl: string,
type: AvatarIconType = AvatarIconType.AVATAR,
-2
View File
@@ -17,7 +17,6 @@ export default defineConfig(({ mode }) => {
POSTHOG_HOST,
POSTHOG_KEY,
AI_VECTOR_DRIVER,
BETA_CONFLUENCE_IMPORTER,
BETA_PUBLIC_SPACES,
} = loadEnv(mode, envPath, "");
@@ -35,7 +34,6 @@ export default defineConfig(({ mode }) => {
POSTHOG_HOST,
POSTHOG_KEY,
AI_VECTOR_DRIVER,
BETA_CONFLUENCE_IMPORTER,
BETA_PUBLIC_SPACES,
},
APP_VERSION: JSON.stringify(process.env.npm_package_version),
-1
View File
@@ -79,7 +79,6 @@
"class-validator": "0.15.1",
"cookie": "1.1.1",
"csv-stringify": "6.8.0",
"entities": "7.0.1",
"fast-bm25": "0.0.5",
"fastify-ip": "2.0.0",
"fs-extra": "11.3.4",
-2
View File
@@ -17,7 +17,6 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { HealthModule } from './integrations/health/health.module';
import { ExportModule } from './integrations/export/export.module';
import { ImportModule } from './integrations/import/import.module';
import { ImportProcessorModule } from './integrations/import/import-processor.module';
import { SecurityModule } from './integrations/security/security.module';
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
@@ -89,7 +88,6 @@ try {
StaticModule,
HealthModule,
ImportModule,
ImportProcessorModule,
ExportModule,
StorageModule.forRootAsync({
imports: [EnvironmentModule],
@@ -5,6 +5,7 @@ import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript';
import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube';
import { TaskList, TaskItem } from '@tiptap/extension-list';
import {
@@ -35,7 +36,6 @@ import {
Mention,
Subpages,
Highlight,
Color,
Indent,
UniqueID,
Columns,
-1
View File
@@ -7,7 +7,6 @@ export const Feature = {
PAGE_PERMISSIONS: 'page:permissions',
AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence',
CONFLUENCE_API_IMPORT: 'import:confluence-api',
DOCX_IMPORT: 'import:docx',
PDF_IMPORT: 'import:pdf',
ATTACHMENT_INDEXING: 'attachment:indexing',
@@ -175,7 +175,44 @@ export class PageService {
}
async nextPagePosition(spaceId: string, parentPageId?: string) {
return this.pageRepo.nextPagePosition(spaceId, parentPageId);
let pagePosition: string;
const lastPageQuery = this.db
.selectFrom('pages')
.select(['position'])
.where('spaceId', '=', spaceId)
.where('deletedAt', 'is', null)
.orderBy('position', (ob) => ob.collate('C').desc())
.limit(1);
if (parentPageId) {
// check for children of this page
const lastPage = await lastPageQuery
.where('parentPageId', '=', parentPageId)
.executeTakeFirst();
if (!lastPage) {
pagePosition = generateJitteredKeyBetween(null, null);
} else {
// if there is an existing page, we should get a position below it
pagePosition = generateJitteredKeyBetween(lastPage.position, null);
}
} else {
// for root page
const lastPage = await lastPageQuery
.where('parentPageId', 'is', null)
.executeTakeFirst();
// if no existing page, make this the first
if (!lastPage) {
pagePosition = generateJitteredKeyBetween(null, null); // we expect "a0"
} else {
// if there is an existing page, we should get a position below it
pagePosition = generateJitteredKeyBetween(lastPage.position, null);
}
}
return pagePosition;
}
async update(
@@ -8,8 +8,6 @@ export class SearchResponseDto {
creatorId: string;
rank: number;
highlight: string;
matchedText: string[];
wholeWord: boolean;
createdAt: Date;
updatedAt: Date;
space: Partial<Space>;
+4 -18
View File
@@ -191,25 +191,11 @@ export class SearchService {
//@ts-ignore
const searchResults = results.map((result: SearchResponseDto) => {
result.wholeWord = true
if (!result.highlight) {
result.matchedText = [];
return result;
if (result.highlight) {
result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ')
.replace(/\s+/g, ' ');
}
result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ')
.replace(/\s+/g, ' ');
result.matchedText = [
...new Set(
Array.from(
result.highlight.matchAll(/<b>([^<]*)<\/b>/gi),
(match) => match[1],
),
),
];
return result;
});
@@ -16,7 +16,6 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
@Injectable()
export class PageRepo {
@@ -607,29 +606,6 @@ export class PageRepo {
);
}
async nextPagePosition(
spaceId: string,
parentPageId?: string,
): Promise<string> {
const lastPageQuery = this.db
.selectFrom('pages')
.select(['position'])
.where('spaceId', '=', spaceId)
.where('deletedAt', 'is', null)
.orderBy('position', (ob) => ob.collate('C').desc())
.limit(1);
const lastPage = parentPageId
? await lastPageQuery
.where('parentPageId', '=', parentPageId)
.executeTakeFirst()
: await lastPageQuery
.where('parentPageId', 'is', null)
.executeTakeFirst();
return generateJitteredKeyBetween(lastPage?.position ?? null, null);
}
/**
* All pages of a space excluding restricted subtrees.
* Used by public spaces; a restricted page hides its whole subtree.
@@ -1,34 +0,0 @@
import { Json, Timestamp, Generated } from '@docmost/db/types/db';
export interface ConfluenceApiImports {
id: Generated<string>;
fileTaskId: string;
siteUrl: string;
authType: string;
authEmail: string | null;
authToken: string | null;
authUsername: string | null;
totalSpaces: Generated<number>;
importedSpaces: Generated<number>;
totalPages: Generated<number>;
importedPages: Generated<number>;
totalUsers: Generated<number>;
importedUsers: Generated<number>;
totalAttachments: Generated<number>;
importedAttachments: Generated<number>;
totalLabels: Generated<number>;
importedLabels: Generated<number>;
totalGroups: Generated<number>;
importedGroups: Generated<number>;
totalRestrictedPages: Generated<number>;
importedRestrictedPages: Generated<number>;
idMapping: Generated<Json>;
warnings: Generated<Json>;
currentPhase: string | null;
cancelled: Generated<boolean>;
spaceKeys: Generated<Json>;
workspaceId: string;
creatorId: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
@@ -1,8 +1,6 @@
import { DB } from '@docmost/db/types/db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
import { ConfluenceApiImports } from './custom.types';
export interface DbInterface extends DB {
pageEmbeddings: PageEmbeddings;
confluenceApiImports: ConfluenceApiImports;
}
@@ -214,13 +214,6 @@ export class EnvironmentService {
return !this.isCloud();
}
isBetaConfluenceImporter(): boolean {
const flag = this.configService
.get<string>('BETA_CONFLUENCE_IMPORTER', 'false')
.toLowerCase();
return flag === 'true';
}
getStripePublishableKey(): string {
return this.configService.get<string>('STRIPE_PUBLISHABLE_KEY');
}
@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { ImportModule } from './import.module';
import { FileTaskProcessor } from './processors/file-task.processor';
@Module({
imports: [ImportModule],
providers: [FileTaskProcessor],
})
export class ImportProcessorModule {}
@@ -3,13 +3,19 @@ import { ImportService } from './services/import.service';
import { ImportController } from './import.controller';
import { StorageModule } from '../storage/storage.module';
import { FileImportTaskService } from './services/file-import-task.service';
import { FileTaskProcessor } from './processors/file-task.processor';
import { ImportAttachmentService } from './services/import-attachment.service';
import { FileTaskController } from './file-task.controller';
import { PageModule } from '../../core/page/page.module';
@Module({
providers: [ImportService, FileImportTaskService, ImportAttachmentService],
exports: [ImportService, ImportAttachmentService, FileImportTaskService],
providers: [
ImportService,
FileImportTaskService,
FileTaskProcessor,
ImportAttachmentService,
],
exports: [ImportService, ImportAttachmentService],
controllers: [ImportController, FileTaskController],
imports: [StorageModule, PageModule],
})
@@ -28,9 +28,6 @@ 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;
@@ -52,19 +49,6 @@ 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> {
const pdfExportService = this.getPdfExportService();
await pdfExportService.generateAndStorePdf(fileTaskId);
@@ -93,8 +77,6 @@ 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);
}
}
@@ -452,7 +452,16 @@ export class ImportAttachmentService {
const audioExtensions = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.webm', '.flac', '.aac']);
if (ext === '.mp4') {
if (ext === '.pdf') {
const $pdf = $('<div>')
.attr('data-type', 'pdf')
.attr('src', apiFilePath)
.attr('data-attachment-id', attachmentId)
.attr('width', '800')
.attr('height', '600');
$a.replaceWith($pdf);
unwrapFromParagraph($, $pdf);
} else if (ext === '.mp4') {
const $video = $('<video>')
.attr('src', apiFilePath)
.attr('data-attachment-id', attachmentId)
@@ -1,32 +0,0 @@
import { nodeIdFromConfluenceAnchor } from './confluence-anchor-id';
describe('nodeIdFromConfluenceAnchor', () => {
it('is deterministic for the same (pageId, anchorName)', () => {
const a = nodeIdFromConfluenceAnchor('page-1', 'My Anchor');
const b = nodeIdFromConfluenceAnchor('page-1', 'My Anchor');
expect(a).toBe(b);
});
it('returns different ids when the anchor name differs', () => {
const a = nodeIdFromConfluenceAnchor('page-1', 'one');
const b = nodeIdFromConfluenceAnchor('page-1', 'two');
expect(a).not.toBe(b);
});
it('returns different ids when the pageId differs', () => {
const a = nodeIdFromConfluenceAnchor('page-1', 'same');
const b = nodeIdFromConfluenceAnchor('page-2', 'same');
expect(a).not.toBe(b);
});
it('returns exactly 12 lowercase a-z characters', () => {
const id = nodeIdFromConfluenceAnchor('page-xyz', 'Section · 1');
expect(id).toHaveLength(12);
expect(id).toMatch(/^[a-z]{12}$/);
});
it('treats an empty anchor name as a valid input', () => {
const id = nodeIdFromConfluenceAnchor('page-1', '');
expect(id).toMatch(/^[a-z]{12}$/);
});
});
@@ -1,28 +0,0 @@
import { createHash } from 'crypto';
// Matches the alphabet used by generateNodeId() in
// packages/editor-ext/src/lib/utils.ts (customAlphabet from nanoid).
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz';
const NODE_ID_LENGTH = 12;
/**
* Returns a deterministic 12-character nodeId for a Confluence anchor.
* The same (pageId, anchorName) pair always produces the same result, so
* cross-page anchor links resolve to the anchor target without a
* precomputed map. The output uses the same alphabet and length as
* generateNodeId() from @docmost/editor-ext, so it is interchangeable
* with editor-generated nodeIds.
*/
export function nodeIdFromConfluenceAnchor(
pageId: string,
anchorName: string,
): string {
const digest = createHash('sha256')
.update(`${pageId}#${anchorName}`)
.digest();
let out = '';
for (let i = 0; i < NODE_ID_LENGTH; i++) {
out += ALPHABET[digest[i] % ALPHABET.length];
}
return out;
}
@@ -1,46 +0,0 @@
import { parseConfluenceEmojiId } from './confluence-emoji';
describe('parseConfluenceEmojiId', () => {
it('parses a single code point id', () => {
expect(parseConfluenceEmojiId('1f600')).toBe('😀');
expect(parseConfluenceEmojiId('1F600')).toBe('😀');
});
it('parses a country flag (two regional indicator code points)', () => {
expect(parseConfluenceEmojiId('1f1f3-1f1ec')).toBe('🇳🇬');
expect(parseConfluenceEmojiId('1f1fa-1f1f8')).toBe('🇺🇸');
});
it('parses a ZWJ sequence (three code points)', () => {
expect(parseConfluenceEmojiId('1f468-200d-1f4bb')).toBe('👨‍💻');
});
it('parses a five-component family ZWJ sequence', () => {
// 👨‍👩‍👧‍👦 = man, ZWJ, woman, ZWJ, girl, ZWJ, boy
expect(parseConfluenceEmojiId('1f468-200d-1f469-200d-1f467-200d-1f466')).toBe(
'👨‍👩‍👧‍👦',
);
});
it('returns null for missing input', () => {
expect(parseConfluenceEmojiId(undefined)).toBeNull();
expect(parseConfluenceEmojiId(null)).toBeNull();
expect(parseConfluenceEmojiId('')).toBeNull();
});
it('returns null when any segment is not pure hex', () => {
expect(parseConfluenceEmojiId('1f600-NG')).toBeNull();
expect(parseConfluenceEmojiId('not-hex')).toBeNull();
expect(parseConfluenceEmojiId('1f600--1f1ec')).toBeNull();
expect(parseConfluenceEmojiId('1f600 1f1ec')).toBeNull();
});
it('returns null when a segment parses to a non-positive value', () => {
expect(parseConfluenceEmojiId('0')).toBeNull();
});
it('returns null for code points outside the valid Unicode range', () => {
// 0x110000 is one past the highest valid code point.
expect(parseConfluenceEmojiId('110000')).toBeNull();
});
});
@@ -1,28 +0,0 @@
/**
* Parse a Confluence emoji id (hex code points joined by hyphens) into a
* Unicode string. Confluence emits ids in both single- and multi-code-point
* forms:
*
* "1f600" → "😀"
* "1f1f3-1f1ec" → "🇳🇬" (flag: Nigeria)
* "1f468-200d-1f4bb" → "👨‍💻" (man technologist, ZWJ sequence)
*
* Returns null when the input is missing, empty, or doesn't parse cleanly as
* hyphen-separated hex code points.
*/
export function parseConfluenceEmojiId(
raw: string | undefined | null,
): string | null {
if (!raw) return null;
const parts = raw.split('-');
if (parts.length === 0) return null;
if (!parts.every((p) => /^[0-9a-fA-F]+$/.test(p))) return null;
const codePoints = parts.map((p) => parseInt(p, 16));
if (codePoints.some((cp) => !Number.isFinite(cp) || cp <= 0)) return null;
try {
return String.fromCodePoint(...codePoints);
} catch {
// Out-of-range code points throw RangeError on String.fromCodePoint.
return null;
}
}
@@ -1,41 +0,0 @@
import { mapConfluenceHighlightColor } from './confluence-highlight-color';
describe('mapConfluenceHighlightColor', () => {
it('maps named DC colours to the Docmost table palette', () => {
expect(mapConfluenceHighlightColor('grey')).toEqual({
color: '#eaecef',
name: 'gray',
});
expect(mapConfluenceHighlightColor('red')).toEqual({
color: '#ffbead',
name: 'red',
});
expect(mapConfluenceHighlightColor('yellow')).toEqual({
color: '#fef1b4',
name: 'yellow',
});
});
it('is case- and whitespace-insensitive', () => {
expect(mapConfluenceHighlightColor(' Grey ')).toEqual({
color: '#eaecef',
name: 'gray',
});
});
it('maps teal to the closest Docmost colour', () => {
expect(mapConfluenceHighlightColor('teal')).toEqual({
color: '#b4d5ff',
name: 'blue',
});
});
it('passes hex values through untouched', () => {
expect(mapConfluenceHighlightColor('#f4f5f7')).toEqual({
color: '#f4f5f7',
});
expect(mapConfluenceHighlightColor('#4c9aff')).toEqual({
color: '#4c9aff',
});
});
});
@@ -1,24 +0,0 @@
const CONFLUENCE_HIGHLIGHT_TO_DOCMOST: Record<
string,
{ color: string; name: string }
> = {
grey: { color: '#eaecef', name: 'gray' },
gray: { color: '#eaecef', name: 'gray' },
blue: { color: '#b4d5ff', name: 'blue' },
teal: { color: '#b4d5ff', name: 'blue' },
green: { color: '#acf5d2', name: 'green' },
yellow: { color: '#fef1b4', name: 'yellow' },
red: { color: '#ffbead', name: 'red' },
purple: { color: '#c1b7f2', name: 'purple' },
};
export function mapConfluenceHighlightColor(colour: string): {
color: string;
name?: string;
} {
return (
CONFLUENCE_HIGHLIGHT_TO_DOCMOST[colour.trim().toLowerCase()] ?? {
color: colour,
}
);
}
@@ -1,149 +0,0 @@
import { load } from 'cheerio';
import { applyConfluenceMarginLeftIndent } from './confluence-indent';
function run(html: string): string {
const $ = load(html);
applyConfluenceMarginLeftIndent($, $.root());
// cheerio's html() includes <html><body>; return the body's inner HTML so
// tests can assert on the meaningful portion.
return $('body').html() ?? $.html();
}
describe('applyConfluenceMarginLeftIndent', () => {
describe('Confluence Cloud (30 px per level, max 6)', () => {
it('maps 30/60/90/120/150/180 px to data-indent 1..6', () => {
const html =
'<p style="margin-left: 30.0px;">L1</p>' +
'<p style="margin-left: 60.0px;">L2</p>' +
'<p style="margin-left: 90.0px;">L3</p>' +
'<p style="margin-left: 120.0px;">L4</p>' +
'<p style="margin-left: 150.0px;">L5</p>' +
'<p style="margin-left: 180.0px;">L6</p>';
const out = run(html);
expect(out).toContain('<p data-indent="1">L1</p>');
expect(out).toContain('<p data-indent="2">L2</p>');
expect(out).toContain('<p data-indent="3">L3</p>');
expect(out).toContain('<p data-indent="4">L4</p>');
expect(out).toContain('<p data-indent="5">L5</p>');
expect(out).toContain('<p data-indent="6">L6</p>');
expect(out).not.toContain('margin-left');
});
});
describe('Confluence Data Center (40 px per level, no upper bound)', () => {
it('maps 40/80/120/160/200/240 px to data-indent 1..6', () => {
const html =
'<p style="margin-left: 40.0px;">one</p>' +
'<p style="margin-left: 80.0px;">two</p>' +
'<p style="margin-left: 120.0px;">three</p>' +
'<p style="margin-left: 160.0px;">four</p>' +
'<p style="margin-left: 200.0px;">five</p>' +
'<p style="margin-left: 240.0px;">six</p>';
const out = run(html);
expect(out).toContain('<p data-indent="1">one</p>');
expect(out).toContain('<p data-indent="2">two</p>');
expect(out).toContain('<p data-indent="3">three</p>');
expect(out).toContain('<p data-indent="4">four</p>');
expect(out).toContain('<p data-indent="5">five</p>');
expect(out).toContain('<p data-indent="6">six</p>');
expect(out).not.toContain('margin-left');
});
it('clamps DC levels above 8 down to 8', () => {
const html =
'<p style="margin-left: 320.0px;">L8</p>' +
'<p style="margin-left: 360.0px;">L9</p>' +
'<p style="margin-left: 600.0px;">L15</p>';
const out = run(html);
expect(out).toContain('<p data-indent="8">L8</p>');
expect(out).toContain('<p data-indent="8">L9</p>');
expect(out).toContain('<p data-indent="8">L15</p>');
});
});
describe('headings', () => {
it('handles indent on h1-h6 the same way as paragraphs', () => {
const html =
'<h1 style="margin-left: 30px;">a</h1>' +
'<h6 style="margin-left: 90px;">b</h6>';
const out = run(html);
expect(out).toContain('<h1 data-indent="1">a</h1>');
expect(out).toContain('<h6 data-indent="3">b</h6>');
});
});
describe('style attribute handling', () => {
it('strips margin-left but preserves other inline styles', () => {
const html =
'<p style="color: red; margin-left: 30px; font-weight: bold;">x</p>';
const out = run(html);
expect(out).toMatch(/<p style="color: red;\s+font-weight: bold;?" data-indent="1">x<\/p>/);
expect(out).not.toContain('margin-left');
});
it('removes the style attribute entirely when only margin-left was set', () => {
// Two values so GCD detection sees a real unit (60 px) instead of
// collapsing to the lone value. The point of this test is the style
// attribute being stripped, not the level number.
const html =
'<p style="margin-left: 60px;">x</p>' +
'<p style="margin-left: 120px;">y</p>';
const out = run(html);
expect(out).toContain('<p data-indent="1">x</p>');
expect(out).toContain('<p data-indent="2">y</p>');
expect(out).not.toContain('style=');
});
});
describe('scope and edge cases', () => {
it('leaves elements without margin-left untouched', () => {
const html = '<p>plain</p><h2>heading</h2>';
const out = run(html);
expect(out).toBe('<p>plain</p><h2>heading</h2>');
});
it('does not touch divs, spans, or list items', () => {
const html =
'<div style="margin-left: 30px;">div</div>' +
'<li style="margin-left: 30px;">li</li>' +
'<span style="margin-left: 30px;">span</span>';
const out = run(html);
expect(out).not.toContain('data-indent');
expect(out).toContain('margin-left: 30px');
});
it('ignores zero, negative, and unparseable margin-left values', () => {
const html =
'<p style="margin-left: 0px;">zero</p>' +
'<p style="margin-left: -30px;">neg</p>' +
'<p style="margin-left: auto;">auto</p>';
const out = run(html);
expect(out).not.toContain('data-indent');
});
it('honors an explicit pxPerLevel override', () => {
// Mixed Cloud-and-DC nominal values forced to 40 px/level interpretation.
const $ = load(
'<p style="margin-left: 40px;">a</p>' +
'<p style="margin-left: 80px;">b</p>',
);
applyConfluenceMarginLeftIndent($, $.root(), { pxPerLevel: 40 });
const out = $('body').html() ?? '';
expect(out).toContain('<p data-indent="1">a</p>');
expect(out).toContain('<p data-indent="2">b</p>');
});
it('returns a no-op when no indented elements are present', () => {
const html = '<p>hi</p>';
const out = run(html);
expect(out).toBe('<p>hi</p>');
});
it('handles a single ambiguous value by clamping to level 1', () => {
// GCD of a single value is the value itself, so 120 / 120 = 1.
const html = '<p style="margin-left: 120px;">only</p>';
const out = run(html);
expect(out).toContain('<p data-indent="1">only</p>');
});
});
});
@@ -1,76 +0,0 @@
import { Cheerio, CheerioAPI } from 'cheerio';
// Maximum indent level supported by the Indent editor extension (see
// packages/editor-ext/src/lib/indent.ts). Values above this clamp down.
const MAX_INDENT_LEVEL = 8;
const MARGIN_LEFT_RE = /margin-left\s*:\s*(-?\d*\.?\d+)\s*px/i;
const MARGIN_LEFT_STRIP_RE = /margin-left\s*:\s*-?\d*\.?\d+\s*px\s*;?/i;
/**
* Confluence encodes paragraph indent as inline `style="margin-left: Npx"`.
* The per-level pixel value differs by edition: Cloud uses 30 (max 6 levels),
* Data Center uses 40 (no upper limit). The HTML-export ZIP path has no
* edition information available, so we auto-detect the per-level unit from
* the GCD of all margin-left values in the document. The API converter can
* pass `pxPerLevel` explicitly when the edition is known.
*
* Levels are written to `data-indent` for the TipTap Indent extension to
* pick up; the margin-left style is stripped from the element so the
* normalized indent doesn't double up with the editor's own indent padding.
*/
export function applyConfluenceMarginLeftIndent(
$: CheerioAPI,
$root: Cheerio<any>,
options?: { pxPerLevel?: number },
): void {
const $els = $root.find('p, h1, h2, h3, h4, h5, h6');
const values: number[] = [];
$els.each((_, el) => {
const style = $(el).attr('style');
if (!style) return;
const match = MARGIN_LEFT_RE.exec(style);
if (!match) return;
const px = parseFloat(match[1]);
if (Number.isFinite(px) && px > 0) values.push(px);
});
if (values.length === 0) return;
const unit = options?.pxPerLevel ?? detectIndentUnit(values);
if (!unit || unit <= 0) return;
$els.each((_, el) => {
const $el = $(el);
const style = $el.attr('style');
if (!style) return;
const match = MARGIN_LEFT_RE.exec(style);
if (!match) return;
const px = parseFloat(match[1]);
if (!Number.isFinite(px) || px <= 0) return;
const level = Math.min(
MAX_INDENT_LEVEL,
Math.max(1, Math.round(px / unit)),
);
$el.attr('data-indent', String(level));
const remaining = style.replace(MARGIN_LEFT_STRIP_RE, '').trim();
if (remaining) {
$el.attr('style', remaining);
} else {
$el.removeAttr('style');
}
});
}
function detectIndentUnit(values: number[]): number {
// Confluence emits floats like "30.0"; round to ints for a clean GCD.
const ints = values.map((v) => Math.round(v)).filter((v) => v > 0);
if (ints.length === 0) return 0;
return ints.reduce((a, b) => gcd(a, b));
}
function gcd(a: number, b: number): number {
while (b !== 0) {
[a, b] = [b, a % b];
}
return a;
}
@@ -11,7 +11,6 @@ export enum FileImportSource {
Generic = 'generic',
Notion = 'notion',
Confluence = 'confluence',
ConfluenceApi = 'confluence-api'
}
export enum FileTaskStatus {
@@ -97,9 +97,6 @@ export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
}
}
import { applyConfluenceMarginLeftIndent } from './confluence-indent';
export { applyConfluenceMarginLeftIndent };
function isBareLink($el: Cheerio<any>): boolean {
const href = $el.attr("href")?.trim();
const text = $el.text().trim();
@@ -111,16 +108,14 @@ function isBareLink($el: Cheerio<any>): boolean {
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
normalizeTableColumnWidths($, $root);
applyConfluenceMarginLeftIndent($, $root);
// Auto-embed only bare links (text equals href) that are the sole meaningful
// child of their parent block. Anything else stays an inline link.
$root.find('a[href]').each((_, el) => {
const $el = $(el);
const url = $el.attr('href')!;
const { provider } = getEmbedUrlAndProvider(url);
if (provider === 'iframe' || !isBareLink($el)) return;
if (!isSoleMeaningfulChild($el, el)) return;
if (provider === 'iframe' || !isBareLink($el)) {
return;
}
const embed = `<div data-type=\"embed\" data-src=\"${url}\" data-provider=\"${provider}\" data-align=\"center\" data-width=\"640\" data-height=\"480\"></div>`;
$el.replaceWith(embed);
@@ -136,21 +131,6 @@ export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
});
}
function isSoleMeaningfulChild(
$el: Cheerio<any>,
rawEl: any,
): boolean {
const $parent = $el.parent();
if ($parent.length === 0) return true;
const others = $parent.contents().toArray().filter((n: any) => {
if (n === rawEl) return false;
if (n.type === 'text') return (n.data ?? '').trim() !== '';
if (n.type === 'tag' && n.name === 'br') return false;
return true;
});
return others.length === 0;
}
const COLUMN_LAYOUTS = [
'',
'',
@@ -4,7 +4,6 @@ export enum QueueName {
GENERAL_QUEUE = '{general-queue}',
BILLING_QUEUE = '{billing-queue}',
FILE_TASK_QUEUE = '{file-task-queue}',
CONFLUENCE_IMPORT_QUEUE = '{confluence-import-queue}',
SEARCH_QUEUE = '{search-queue}',
AI_QUEUE = '{ai-queue}',
HISTORY_QUEUE = '{history-queue}',
@@ -33,7 +32,6 @@ 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',
@@ -1,11 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { createQueueRegistrations } from './queue.registrations';
// Global so @InjectQueue tokens resolve anywhere, in the app and in worker contexts.
@Global()
@Module({
imports: [...createQueueRegistrations()],
exports: [BullModule],
})
export class QueueProducersModule {}
@@ -1,18 +1,115 @@
import { Global, Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { EnvironmentService } from '../environment/environment.service';
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
import { QueueName } from './constants';
import { GeneralQueueProcessor } from './processors/general-queue.processor';
import { bullConfigFactory } from './queue.registrations';
import { QueueProducersModule } from './queue-producers.module';
@Global()
@Module({
imports: [
BullModule.forRootAsync({
useFactory: bullConfigFactory,
useFactory: (environmentService: EnvironmentService) => {
const redisConfig = parseRedisUrl(environmentService.getRedisUrl());
return {
connection: {
host: redisConfig.host,
port: redisConfig.port,
username: redisConfig.username,
password: redisConfig.password,
db: redisConfig.db,
family: redisConfig.family,
tls: redisConfig.tls,
retryStrategy: createRetryStrategy(),
},
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 20 * 1000,
},
removeOnComplete: {
count: 200,
},
removeOnFail: {
count: 100,
},
},
};
},
inject: [EnvironmentService],
}),
QueueProducersModule,
BullModule.registerQueue({
name: QueueName.EMAIL_QUEUE,
}),
BullModule.registerQueue({
name: QueueName.ATTACHMENT_QUEUE,
}),
BullModule.registerQueue({
name: QueueName.GENERAL_QUEUE,
}),
BullModule.registerQueue({
name: QueueName.BILLING_QUEUE,
}),
BullModule.registerQueue({
name: QueueName.FILE_TASK_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 1,
},
}),
BullModule.registerQueue({
name: QueueName.SEARCH_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 2,
},
}),
BullModule.registerQueue({
name: QueueName.AI_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 1,
},
}),
BullModule.registerQueue({
name: QueueName.HISTORY_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 2,
},
}),
BullModule.registerQueue({
name: QueueName.NOTIFICATION_QUEUE,
}),
BullModule.registerQueue({
name: QueueName.AUDIT_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 3,
},
}),
BullModule.registerQueue({
name: QueueName.SIEM_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 1,
},
}),
BullModule.registerQueue({
name: QueueName.BASE_QUEUE,
defaultJobOptions: {
attempts: 2,
removeOnComplete: { count: 200 },
removeOnFail: { count: 100 },
},
}),
],
exports: [BullModule],
providers: [GeneralQueueProcessor],
@@ -1,107 +0,0 @@
import { BullModule } from '@nestjs/bullmq';
import { EnvironmentService } from '../environment/environment.service';
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
import { QueueName } from './constants';
export const bullConfigFactory = (environmentService: EnvironmentService) => {
const redisConfig = parseRedisUrl(environmentService.getRedisUrl());
return {
connection: {
host: redisConfig.host,
port: redisConfig.port,
username: redisConfig.username,
password: redisConfig.password,
db: redisConfig.db,
family: redisConfig.family,
tls: redisConfig.tls,
retryStrategy: createRetryStrategy(),
},
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 20 * 1000,
},
removeOnComplete: {
count: 200,
},
removeOnFail: {
count: 100,
},
},
};
};
export const createQueueRegistrations = () => [
BullModule.registerQueue({
name: QueueName.EMAIL_QUEUE,
}),
BullModule.registerQueue({
name: QueueName.ATTACHMENT_QUEUE,
}),
BullModule.registerQueue({
name: QueueName.GENERAL_QUEUE,
}),
BullModule.registerQueue({
name: QueueName.BILLING_QUEUE,
}),
BullModule.registerQueue({
name: QueueName.FILE_TASK_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 1,
},
}),
BullModule.registerQueue({
name: QueueName.SEARCH_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 2,
},
}),
BullModule.registerQueue({
name: QueueName.AI_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 1,
},
}),
BullModule.registerQueue({
name: QueueName.HISTORY_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 2,
},
}),
BullModule.registerQueue({
name: QueueName.NOTIFICATION_QUEUE,
}),
BullModule.registerQueue({
name: QueueName.AUDIT_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 3,
},
}),
BullModule.registerQueue({
name: QueueName.SIEM_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 1,
},
}),
BullModule.registerQueue({
name: QueueName.BASE_QUEUE,
defaultJobOptions: {
attempts: 2,
removeOnComplete: { count: 200 },
removeOnFail: { count: 100 },
},
}),
];
@@ -54,8 +54,6 @@ export class StaticModule implements OnModuleInit {
this.environmentService.getAiVectorDriver() === 'turbopuffer'
? 'turbopuffer'
: undefined,
BETA_CONFLUENCE_IMPORTER:
this.environmentService.isBetaConfluenceImporter(),
};
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
-1
View File
@@ -23,7 +23,6 @@ export * from "./lib/embed-provider";
export * from "./lib/subpages";
export * from "./lib/transclusion";
export * from "./lib/highlight";
export * from "./lib/text-color";
export * from "./lib/indent";
export * from "./lib/heading/heading";
export * from "./lib/unique-id";
+1 -3
View File
@@ -16,11 +16,9 @@ export const Highlight = TiptapHighlight.extend<HighlightOptions>({
return {};
}
// --mark-bg lets CSS derive a legible dark-mode variant for
// arbitrary (imported) colors.
return {
"data-color": attributes.color,
style: `background-color: ${attributes.color}; --mark-bg: ${attributes.color}; color: inherit`,
style: `background-color: ${attributes.color}; color: inherit`,
};
},
},
@@ -39,11 +39,7 @@ declare module "@tiptap/core" {
/**
* @description Set search term in extension.
*/
setSearchTerms: (searchTerms: string[]) => ReturnType;
/**
* @description Set whole word search in extension.
*/
setWholeWord: (wholeWord: boolean) => ReturnType;
setSearchTerm: (searchTerm: string) => ReturnType;
/**
* @description Set replace term in extension.
*/
@@ -86,26 +82,13 @@ interface TextNodesWithPosition {
}
const getRegex = (
searchTerms: string[],
s: string,
disableRegex: boolean,
caseSensitive: boolean,
wholeWord: boolean,
): RegExp => {
const terms = searchTerms.filter(Boolean).sort((a, b) => b.length - a.length);
const pattern = terms
.map((term) =>
disableRegex ? term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : term,
)
.join("|");
const finalPattern = wholeWord
? `(?<![\\p{L}\\p{N}_])(?:${pattern})(?![\\p{L}\\p{N}_])`
: pattern;
return new RegExp(
finalPattern,
caseSensitive ? "gu" : "giu",
return RegExp(
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : s,
caseSensitive ? "gu" : "gui",
);
};
@@ -272,14 +255,12 @@ export interface SearchAndReplaceOptions {
}
export interface SearchAndReplaceStorage {
searchTerms: string[];
searchTerm: string;
replaceTerm: string;
results: Range[];
lastSearchTerms: string[];
lastSearchTerm: string;
caseSensitive: boolean;
lastCaseSensitive: boolean;
wholeWord: boolean;
lastWholeWord: boolean;
resultIndex: number;
lastResultIndex: number;
}
@@ -299,13 +280,11 @@ export const SearchAndReplace = Extension.create<
addStorage() {
return {
searchTerms: [],
searchTerm: "",
replaceTerm: "",
results: [],
lastSearchTerms: [],
lastSearchTerm: "",
caseSensitive: false,
wholeWord: false,
lastWholeWord: false,
lastCaseSensitive: false,
resultIndex: 0,
lastResultIndex: 0,
@@ -314,21 +293,10 @@ export const SearchAndReplace = Extension.create<
addCommands() {
return {
setSearchTerms:
(searchTerms: string[]) =>
setSearchTerm:
(searchTerm: string) =>
({ editor }) => {
editor.storage.searchAndReplace.searchTerms = searchTerms.filter(Boolean);
// clear whole word by default
// should remove if whole word toggle is added to search and replace dialog
editor.storage.searchAndReplace.wholeWord = false;
return false;
},
setWholeWord:
(wholeWord: boolean) =>
({ editor }) => {
editor.storage.searchAndReplace.wholeWord = wholeWord;
editor.storage.searchAndReplace.searchTerm = searchTerm;
return false;
},
@@ -441,10 +409,8 @@ export const SearchAndReplace = Extension.create<
const editor = this.editor;
const { searchResultClass, disableRegex } = this.options;
const setLastSearchTerms = (terms: string[]) =>
(editor.storage.searchAndReplace.lastSearchTerms = [...terms]);
const setLastWholeWord = (t: boolean) =>
(editor.storage.searchAndReplace.lastWholeWord = t);
const setLastSearchTerm = (t: string) =>
(editor.storage.searchAndReplace.lastSearchTerm = t);
const setLastCaseSensitive = (t: boolean) =>
(editor.storage.searchAndReplace.lastCaseSensitive = t);
const setLastResultIndex = (t: number) =>
@@ -459,47 +425,37 @@ export const SearchAndReplace = Extension.create<
const storage = editor.storage.searchAndReplace;
if (!storage) return oldState;
const {
searchTerms,
lastCaseSensitive,
lastSearchTerms,
searchTerm,
lastSearchTerm,
caseSensitive,
wholeWord,
lastWholeWord,
lastCaseSensitive,
resultIndex,
lastResultIndex,
} = storage;
if (
!docChanged &&
searchTerms.length === lastSearchTerms.length &&
searchTerms.every((term, index) => term === lastSearchTerms[index]) &&
lastSearchTerm === searchTerm &&
lastCaseSensitive === caseSensitive &&
lastWholeWord === wholeWord &&
lastResultIndex === resultIndex
)
return oldState;
setLastSearchTerms(searchTerms);
setLastSearchTerm(searchTerm);
setLastCaseSensitive(caseSensitive);
setLastWholeWord(wholeWord);
setLastResultIndex(resultIndex);
if (searchTerms.length === 0) {
if (!searchTerm) {
editor.storage.searchAndReplace.results = [];
return DecorationSet.empty;
}
const { decorationsToReturn, results } = processSearches(
doc,
getRegex(
searchTerms,
disableRegex,
caseSensitive,
wholeWord,
),
searchResultClass,
resultIndex,
);
doc,
getRegex(searchTerm, disableRegex, caseSensitive),
searchResultClass,
resultIndex,
);
editor.storage.searchAndReplace.results = results;
+1 -1
View File
@@ -19,7 +19,7 @@ export const TableCell = TiptapTableCell.extend({
return {};
}
return {
style: `background-color: ${attributes.backgroundColor}; --cell-bg: ${attributes.backgroundColor}`,
style: `background-color: ${attributes.backgroundColor}`,
"data-background-color": attributes.backgroundColor,
};
},
+1 -1
View File
@@ -19,7 +19,7 @@ export const TableHeader = TiptapTableHeader.extend({
return {};
}
return {
style: `background-color: ${attributes.backgroundColor}; --cell-bg: ${attributes.backgroundColor}`,
style: `background-color: ${attributes.backgroundColor}`,
"data-background-color": attributes.backgroundColor,
};
},
-35
View File
@@ -1,35 +0,0 @@
import { getStyleProperty } from "@tiptap/core";
import { Color as TiptapColor } from "@tiptap/extension-color";
export const Color = TiptapColor.extend({
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
color: {
default: null,
parseHTML: (element) => {
const value =
element.getAttribute("data-text-color") ??
getStyleProperty(element, "color") ??
element.style.color;
return value?.replace(/['"]+/g, "") || null;
},
renderHTML: (attributes) => {
if (!attributes.color) {
return {};
}
// --text-color lets CSS derive a legible dark-mode variant for
// arbitrary (imported) colors.
return {
"data-text-color": attributes.color,
style: `color: ${attributes.color}; --text-color: ${attributes.color}`,
};
},
},
},
},
];
},
});
-3
View File
@@ -639,9 +639,6 @@ importers:
csv-stringify:
specifier: 6.8.0
version: 6.8.0
entities:
specifier: 7.0.1
version: 7.0.1
fast-bm25:
specifier: 0.0.5
version: 0.0.5(typescript@5.9.3)