mirror of
https://github.com/docmost/docmost.git
synced 2026-08-20 02:54:11 +08:00
Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f0ebdd0bf | ||
|
|
c093c18bf3 | ||
|
|
8ba4792e25 | ||
|
|
ea59912c7e | ||
|
|
06d7e24676 | ||
|
|
3219bd7160 | ||
|
|
8b73343b6c | ||
|
|
809f6eb3be | ||
|
|
5d8e9fde8b | ||
|
|
dacaaf5629 | ||
|
|
1dd7d79453 | ||
|
|
ecc6b974b5 | ||
|
|
db3ff54da1 | ||
|
|
defc1f4ba5 | ||
|
|
9414a38215 | ||
|
|
089286f6cf | ||
|
|
737cd67965 | ||
|
|
a0b2ac6ae3 | ||
|
|
50e81ade57 | ||
|
|
228fe14cd8 | ||
|
|
2fe13daa09 | ||
|
|
7439da2f6e | ||
|
|
305fd40686 | ||
|
|
4bd51d7404 | ||
|
|
44086cbb01 | ||
|
|
a81e5c42ec | ||
|
|
65af1cc2a4 | ||
|
|
231f0deeb0 | ||
|
|
b9d8612ecd | ||
|
|
e76c138e4c | ||
|
|
e146cfd075 | ||
|
|
efcca2e4fd | ||
|
|
3b57ce7c89 | ||
|
|
86687d2bf2 | ||
|
|
15f893184e | ||
|
|
dc3f7b0be4 | ||
|
|
88e057c76e | ||
|
|
89bf9f3078 | ||
|
|
b0f7c11fb3 | ||
|
|
feb5167771 | ||
|
|
97a1248818 | ||
|
|
b7d7ad4521 | ||
|
|
79df1229cb | ||
|
|
812d0765b5 | ||
|
|
234bd13453 | ||
|
|
affd2e6740 | ||
|
|
37e652ade5 | ||
|
|
38a847f305 | ||
|
|
7c80688698 | ||
|
|
0320e07bb1 | ||
|
|
1982a0ed1e | ||
|
|
fcb2b08cf3 | ||
|
|
e5ff58a73a | ||
|
|
d3bb105366 | ||
|
|
bbb1b5eb26 | ||
|
|
3e66aff1e2 | ||
|
|
89442f5154 | ||
|
|
12ef426541 | ||
|
|
93ff3f43b7 | ||
|
|
cbf03fc956 | ||
|
|
68309247b5 | ||
|
|
474ff6c629 | ||
|
|
c5e9be6b36 | ||
|
|
00e8c53852 |
+1
-1
@@ -42,7 +42,7 @@ RUN chown -R node:node /app
|
||||
|
||||
USER node
|
||||
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
RUN pnpm install --frozen-lockfile --prod && rm -rf /home/node/.cache/pnpm
|
||||
|
||||
RUN mkdir -p /app/data/storage
|
||||
|
||||
|
||||
@@ -50,9 +50,9 @@
|
||||
"katex": "0.16.40",
|
||||
"lowlight": "3.3.0",
|
||||
"mantine-form-zod-resolver": "1.3.0",
|
||||
"mermaid": "11.15.0",
|
||||
"mermaid": "11.16.1",
|
||||
"mitt": "3.0.1",
|
||||
"nanoid": "3.3.8",
|
||||
"nanoid": "3.3.17",
|
||||
"posthog-js": "1.391.2",
|
||||
"react": "19.2.7",
|
||||
"react-clear-modal": "^2.0.18",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"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",
|
||||
@@ -29,7 +30,9 @@
|
||||
"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",
|
||||
@@ -57,6 +60,10 @@
|
||||
"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",
|
||||
@@ -76,6 +83,7 @@
|
||||
"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",
|
||||
@@ -150,6 +158,9 @@
|
||||
"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.",
|
||||
@@ -186,6 +197,7 @@
|
||||
"Setup workspace": "Setup workspace",
|
||||
"Sign In": "Sign In",
|
||||
"Sign Up": "Sign Up",
|
||||
"Site": "Site",
|
||||
"Slug": "Slug",
|
||||
"Space": "Space",
|
||||
"Space description": "Space description",
|
||||
@@ -194,10 +206,13 @@
|
||||
"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",
|
||||
@@ -211,6 +226,8 @@
|
||||
"Untitled": "Untitled",
|
||||
"Updated successfully": "Updated successfully",
|
||||
"User": "User",
|
||||
"Users": "Users",
|
||||
"users": "users",
|
||||
"Workspace": "Workspace",
|
||||
"Workspace Name": "Workspace Name",
|
||||
"Workspace settings": "Workspace settings",
|
||||
@@ -387,6 +404,8 @@
|
||||
"Insert horizontal rule divider": "Insert horizontal rule divider",
|
||||
"Page break": "Page break",
|
||||
"Insert a page break for printing.": "Insert a page break for printing.",
|
||||
"Footnote": "Footnote",
|
||||
"Insert a footnote reference.": "Insert a footnote reference.",
|
||||
"Upload any image from your device.": "Upload any image from your device.",
|
||||
"Upload any video from your device.": "Upload any video from your device.",
|
||||
"Upload any audio from your device.": "Upload any audio from your device.",
|
||||
@@ -1289,5 +1308,16 @@
|
||||
"{{count}} rows deleted_one": "1 row deleted",
|
||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||
"{{count}} selected_one": "1 selected",
|
||||
"{{count}} selected_other": "{{count}} selected"
|
||||
"{{count}} selected_other": "{{count}} selected",
|
||||
"Compare": "Compare",
|
||||
"Compare versions": "Compare versions",
|
||||
"Select version from {{date}}": "Select version from {{date}}",
|
||||
"Version actions for {{date}}": "Version actions for {{date}}",
|
||||
"Comparing {{newer}} and {{older}}": "Comparing {{newer}} and {{older}}",
|
||||
"Exit compare": "Exit compare",
|
||||
"Search attachments...": "Search attachments...",
|
||||
"Error loading attachments.": "Error loading attachments.",
|
||||
"No attachments on this page yet.": "No attachments on this page yet.",
|
||||
"Uploaded by {{name}}": "Uploaded by {{name}}",
|
||||
"Download {{name}}": "Download {{name}}"
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import FavoritesPage from "@/pages/favorites/favorites-page";
|
||||
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
||||
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
||||
import LabelPage from "@/pages/label/label-page";
|
||||
import ConfluenceImportPage from "@/ee/confluence-import/pages/confluence-import.tsx";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
@@ -128,6 +129,10 @@ export default function App() {
|
||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||
<Route path={"audit"} element={<AuditLogs />} />
|
||||
<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,7 @@
|
||||
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
|
||||
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, { ReactNode, useEffect } from "react";
|
||||
@@ -28,7 +29,11 @@ export default function Aside() {
|
||||
|
||||
switch (tab) {
|
||||
case "comments":
|
||||
component = <CommentListWithTabs />;
|
||||
component = (
|
||||
<CommentErrorBoundary>
|
||||
<CommentListWithTabs />
|
||||
</CommentErrorBoundary>
|
||||
);
|
||||
title = "Comments";
|
||||
break;
|
||||
case "toc":
|
||||
|
||||
@@ -15,11 +15,12 @@ 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 { isCloud } from "@/lib/config.ts";
|
||||
import { isBetaConfluenceImporter, isCloud } from "@/lib/config.ts";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
|
||||
@@ -51,6 +52,7 @@ type DataItem = {
|
||||
feature?: string;
|
||||
role?: "admin" | "owner";
|
||||
env?: "cloud" | "selfhosted";
|
||||
show?: () => boolean;
|
||||
};
|
||||
|
||||
type DataGroup = {
|
||||
@@ -125,6 +127,14 @@ const groupedData: DataGroup[] = [
|
||||
role: "owner",
|
||||
env: "selfhosted",
|
||||
},
|
||||
{
|
||||
label: "Import",
|
||||
icon: IconFileImport,
|
||||
path: "/settings/import/confluence",
|
||||
feature: Feature.CONFLUENCE_API_IMPORT,
|
||||
role: "admin",
|
||||
show: () => isBetaConfluenceImporter(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -158,6 +168,7 @@ 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;
|
||||
|
||||
@@ -255,6 +255,7 @@ export default function ChatInput({
|
||||
},
|
||||
content: "",
|
||||
editable: true,
|
||||
textDirection: "auto",
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
autofocus: autofocus ? "end" : false,
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import SettingsTitle from "@/components/settings/settings-title";
|
||||
import { ConfluenceIcon } from "@/components/icons/confluence-icon";
|
||||
import ConfluenceImportModal from "@/ee/confluence-import/components/confluence-import-modal";
|
||||
import ConfluenceImportHistory from "@/ee/confluence-import/components/confluence-import-history";
|
||||
import { getAppName } from "@/lib/config";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
|
||||
export default function ConfluenceImportPage() {
|
||||
const { t } = useTranslation();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const hasConfluenceImport = useHasFeature(Feature.CONFLUENCE_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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { listConfluenceImports } from "@/ee/confluence-import/services/confluence-import-service";
|
||||
|
||||
export const confluenceImportsQueryKey = ["confluence-imports"] as const;
|
||||
|
||||
export function useConfluenceImportsQuery() {
|
||||
return useQuery({
|
||||
queryKey: confluenceImportsQueryKey,
|
||||
queryFn: listConfluenceImports,
|
||||
refetchInterval: (query) => {
|
||||
const hasRunning = query.state.data?.items?.some(
|
||||
(i) => i.status === "processing",
|
||||
);
|
||||
return hasRunning ? 3000 : false;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
ConfluenceCredentials,
|
||||
ImportStatusResponse,
|
||||
ListImportsResponse,
|
||||
ListSpacesResponse,
|
||||
StartImportResponse,
|
||||
TestConnectionResponse,
|
||||
} from "@/ee/confluence-import/types/confluence-import.types";
|
||||
|
||||
export async function testConfluenceConnection(
|
||||
data: ConfluenceCredentials,
|
||||
): Promise<TestConnectionResponse> {
|
||||
const req = await api.post<TestConnectionResponse>(
|
||||
"/confluence-import/test-connection",
|
||||
data,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function listConfluenceSpaces(
|
||||
data: ConfluenceCredentials,
|
||||
): Promise<ListSpacesResponse> {
|
||||
const req = await api.post<ListSpacesResponse>(
|
||||
"/confluence-import/spaces",
|
||||
data,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function startConfluenceImport(
|
||||
data: ConfluenceCredentials & { spaceKeys?: string[] },
|
||||
): Promise<StartImportResponse> {
|
||||
const req = await api.post<StartImportResponse>(
|
||||
"/confluence-import/start",
|
||||
data,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getConfluenceImportStatus(
|
||||
fileTaskId: string,
|
||||
): Promise<ImportStatusResponse> {
|
||||
const req = await api.post<ImportStatusResponse>(
|
||||
"/confluence-import/status",
|
||||
{ fileTaskId },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function listConfluenceImports(): Promise<ListImportsResponse> {
|
||||
const req = await api.post<ListImportsResponse>("/confluence-import/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;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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[];
|
||||
};
|
||||
@@ -7,6 +7,7 @@ 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,7 +53,14 @@ export function PagePermissionItem({
|
||||
{isCurrentUser && <Text span c="dimmed"> ({t("You")})</Text>}
|
||||
</AutoTooltipText>
|
||||
<AutoTooltipText fz="xs" c="dimmed">
|
||||
{member.type === "user" ? member.email : formatMemberCount(member.memberCount, t)}
|
||||
{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)}
|
||||
</AutoTooltipText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,7 @@ export default function ReadonlyTemplateEditor({
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
textDirection="auto"
|
||||
extensions={extensions}
|
||||
content={template.content}
|
||||
/>
|
||||
|
||||
@@ -87,6 +87,7 @@ export default function TemplateEditor() {
|
||||
const editor = useEditor({
|
||||
extensions: templateExtensions,
|
||||
content: "",
|
||||
textDirection: "auto",
|
||||
editorProps: {
|
||||
scrollThreshold: 80,
|
||||
scrollMargin: 80,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ThemeIcon } from "@mantine/core";
|
||||
import {
|
||||
IconFile,
|
||||
IconFileTypeCsv,
|
||||
IconFileTypeDocx,
|
||||
IconFileTypePdf,
|
||||
IconFileTypePpt,
|
||||
IconFileTypeXls,
|
||||
IconFileZip,
|
||||
IconMovie,
|
||||
IconMusic,
|
||||
IconPhoto,
|
||||
type Icon,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
const EXT_ICONS: Record<string, { icon: Icon; color: string }> = {
|
||||
".pdf": { icon: IconFileTypePdf, color: "red" },
|
||||
".doc": { icon: IconFileTypeDocx, color: "blue" },
|
||||
".docx": { icon: IconFileTypeDocx, color: "blue" },
|
||||
".xls": { icon: IconFileTypeXls, color: "teal" },
|
||||
".xlsx": { icon: IconFileTypeXls, color: "teal" },
|
||||
".csv": { icon: IconFileTypeCsv, color: "teal" },
|
||||
".ppt": { icon: IconFileTypePpt, color: "orange" },
|
||||
".pptx": { icon: IconFileTypePpt, color: "orange" },
|
||||
".zip": { icon: IconFileZip, color: "gray" },
|
||||
".rar": { icon: IconFileZip, color: "gray" },
|
||||
".7z": { icon: IconFileZip, color: "gray" },
|
||||
".tar": { icon: IconFileZip, color: "gray" },
|
||||
".gz": { icon: IconFileZip, color: "gray" },
|
||||
};
|
||||
|
||||
const MIME_ICONS: Array<{ prefix: string; icon: Icon; color: string }> = [
|
||||
{ prefix: "image/", icon: IconPhoto, color: "grape" },
|
||||
{ prefix: "video/", icon: IconMovie, color: "violet" },
|
||||
{ prefix: "audio/", icon: IconMusic, color: "pink" },
|
||||
];
|
||||
|
||||
interface AttachmentFileIconProps {
|
||||
fileExt?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export function AttachmentFileIcon({
|
||||
fileExt,
|
||||
mimeType,
|
||||
}: AttachmentFileIconProps) {
|
||||
const byExt = fileExt ? EXT_ICONS[fileExt.toLowerCase()] : undefined;
|
||||
const byMime = mimeType
|
||||
? MIME_ICONS.find((entry) => mimeType.startsWith(entry.prefix))
|
||||
: undefined;
|
||||
const { icon: FileIcon, color } = byExt ??
|
||||
byMime ?? { icon: IconFile, color: "gray" };
|
||||
|
||||
return (
|
||||
<ThemeIcon variant="light" color={color} size={40} radius="md">
|
||||
<FileIcon size={22} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconDownload } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SearchInput } from "@/components/common/search-input.tsx";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { usePageAttachmentsQuery } from "@/features/attachments/queries/attachment-query.ts";
|
||||
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { AttachmentFileIcon } from "@/features/attachments/components/attachment-file-icon.tsx";
|
||||
import { formatBytes } from "@/lib";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { formattedDate } from "@/lib/time.ts";
|
||||
|
||||
interface PageAttachmentsModalProps {
|
||||
pageId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PageAttachmentsModal({
|
||||
pageId,
|
||||
open,
|
||||
onClose,
|
||||
}: PageAttachmentsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={onClose}
|
||||
title={t("Attachments")}
|
||||
size={800}
|
||||
closeButtonProps={{ "aria-label": t("Close") }}
|
||||
>
|
||||
<PageAttachmentsList pageId={pageId} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function PageAttachmentsList({ pageId }: { pageId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = usePageAttachmentsQuery(pageId, search);
|
||||
|
||||
const attachments = useMemo(
|
||||
() => data?.pages.flatMap((page) => page.items) ?? [],
|
||||
[data],
|
||||
);
|
||||
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = loadMoreRef.current;
|
||||
if (!sentinel || !hasNextPage) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && !isFetching) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchNextPage, hasNextPage, isFetching]);
|
||||
|
||||
const handleSearch = useCallback((value: string) => setSearch(value), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchInput
|
||||
onSearch={handleSearch}
|
||||
placeholder={t("Search attachments...")}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : isError ? (
|
||||
<Center py="xl">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Error loading attachments.")}
|
||||
</Text>
|
||||
</Center>
|
||||
) : attachments.length === 0 ? (
|
||||
<Center py="xl">
|
||||
<Text size="sm" c="dimmed">
|
||||
{search
|
||||
? t("No results found")
|
||||
: t("No attachments on this page yet.")}
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={480} type="scroll" scrollbarSize={5}>
|
||||
{attachments.map((attachment) => (
|
||||
<AttachmentRow key={attachment.id} attachment={attachment} />
|
||||
))}
|
||||
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
|
||||
{isFetchingNextPage && (
|
||||
<Center py="sm">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentRow({ attachment }: { attachment: IPageAttachment }) {
|
||||
const { t } = useTranslation();
|
||||
const fileUrl = getFileUrl(attachment.url);
|
||||
|
||||
return (
|
||||
<Group wrap="nowrap" gap="md" py="sm" pr="xs">
|
||||
<AttachmentFileIcon
|
||||
fileExt={attachment.fileExt}
|
||||
mimeType={attachment.mimeType}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Anchor
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
fw={500}
|
||||
c="inherit"
|
||||
truncate="end"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
{attachment.fileName}
|
||||
</Anchor>
|
||||
<Text size="xs" c="dimmed" mt={2} truncate="end">
|
||||
{formatBytes(Number(attachment.fileSize))}
|
||||
{" · "}
|
||||
{formattedDate(new Date(attachment.createdAt))}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{attachment.creator && (
|
||||
<Tooltip
|
||||
label={t("Uploaded by {{name}}", { name: attachment.creator.name })}
|
||||
withArrow
|
||||
>
|
||||
<CustomAvatar
|
||||
avatarUrl={attachment.creator.avatarUrl}
|
||||
name={attachment.creator.name}
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip label={t("Download attachment")} withArrow>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={fileUrl}
|
||||
download={attachment.fileName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("Download {{name}}", { name: attachment.fileName })}
|
||||
>
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
InfiniteData,
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
UseInfiniteQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { getPageAttachments } from "@/features/attachments/services/attachment-service.ts";
|
||||
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export function usePageAttachmentsQuery(
|
||||
pageId: string,
|
||||
search?: string,
|
||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageAttachment>, unknown>> {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["page-attachments", pageId, search],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getPageAttachments(pageId, { cursor: pageParam, query: search }),
|
||||
enabled: !!pageId,
|
||||
gcTime: 0,
|
||||
placeholderData: keepPreviousData,
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,17 @@ import loadImage from "blueimp-load-image";
|
||||
import {
|
||||
AvatarIconType,
|
||||
IAttachment,
|
||||
IPageAttachment,
|
||||
} from "@/features/attachments/types/attachment.types.ts";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
|
||||
export async function getPageAttachments(
|
||||
pageId: string,
|
||||
params?: QueryParams,
|
||||
): Promise<IPagination<IPageAttachment>> {
|
||||
const req = await api.post("/pages/attachments", { pageId, ...params });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
async function compressAndResizeIcon(
|
||||
file: File,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export {
|
||||
getPageAttachments,
|
||||
uploadIcon,
|
||||
uploadUserAvatar,
|
||||
uploadSpaceIcon,
|
||||
|
||||
@@ -15,6 +15,15 @@ export interface IAttachment {
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface IPageAttachment extends IAttachment {
|
||||
url: string;
|
||||
creator: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export enum AvatarIconType {
|
||||
AVATAR = "avatar",
|
||||
SPACE_ICON = "space-icon",
|
||||
|
||||
@@ -166,6 +166,18 @@ 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,6 +11,7 @@ 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";
|
||||
@@ -170,14 +171,16 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<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} />
|
||||
</CommentErrorBoundary>
|
||||
</Stack>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { Mention, LinkExtension } from "@docmost/editor-ext";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Mention, LinkExtension, Color } from "@docmost/editor-ext";
|
||||
import classes from "./comment.module.css";
|
||||
import { useFocusWithin } from "@mantine/hooks";
|
||||
import clsx from "clsx";
|
||||
@@ -11,6 +12,8 @@ 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;
|
||||
@@ -49,6 +52,8 @@ const CommentEditor = forwardRef(
|
||||
placeholder: placeholder || t("Reply..."),
|
||||
}),
|
||||
LinkExtension,
|
||||
TextStyle,
|
||||
Color,
|
||||
EmojiCommand,
|
||||
Mention.configure({
|
||||
suggestion: {
|
||||
@@ -66,6 +71,11 @@ const CommentEditor = forwardRef(
|
||||
return ReactNodeViewRenderer(MentionView);
|
||||
},
|
||||
}),
|
||||
TableKit,
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
@@ -103,6 +113,7 @@ const CommentEditor = forwardRef(
|
||||
},
|
||||
content: defaultContent,
|
||||
editable,
|
||||
textDirection: "auto",
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
autofocus: (autofocus && "end") || false,
|
||||
@@ -112,7 +123,12 @@ 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]);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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,6 +53,10 @@
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
.ProseMirror :global(.tableWrapper) table {
|
||||
min-width: 100% !important;
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IconMathFunction,
|
||||
IconRotate2,
|
||||
IconSitemap,
|
||||
IconSuperscript,
|
||||
IconTable,
|
||||
IconTag,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -270,6 +271,12 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
|
||||
>
|
||||
{t("Math block")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconSuperscript size={16} />}
|
||||
onClick={() => editor.chain().focus().addFootnote().run()}
|
||||
>
|
||||
{t("Footnote")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
IconTag,
|
||||
IconMoodSmile,
|
||||
IconRotate2,
|
||||
IconSuperscript,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
CommandProps,
|
||||
@@ -177,6 +178,16 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setPageBreak().run(),
|
||||
},
|
||||
{
|
||||
title: "Footnote",
|
||||
description: "Insert a footnote reference.",
|
||||
searchTerms: ["footnote", "reference", "citation", "note"],
|
||||
icon: IconSuperscript,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
editor.commands.addFootnote();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Image",
|
||||
description: "Upload any image from your device.",
|
||||
|
||||
@@ -40,6 +40,7 @@ export default function TransclusionContent({ content }: Props) {
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
textDirection="auto"
|
||||
extensions={extensions}
|
||||
content={content as any}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { markInputRule } from "@tiptap/core";
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
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";
|
||||
@@ -9,7 +10,6 @@ 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,6 +53,7 @@ import {
|
||||
Subpages,
|
||||
Heading,
|
||||
Highlight,
|
||||
Color,
|
||||
Indent,
|
||||
UniqueID,
|
||||
SharedStorage,
|
||||
@@ -63,6 +64,9 @@ import {
|
||||
TransclusionReference,
|
||||
TableView,
|
||||
BaseEmbed as BaseEmbedNode,
|
||||
Footnotes,
|
||||
Footnote,
|
||||
FootnoteReference,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -132,6 +136,7 @@ lowlight.register("scala", scala);
|
||||
// @ts-ignore
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
document: false,
|
||||
heading: false,
|
||||
undoRedo: false,
|
||||
link: false,
|
||||
@@ -143,6 +148,9 @@ export const mainExtensions = [
|
||||
codeBlock: false,
|
||||
code: false,
|
||||
}),
|
||||
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
|
||||
@@ -203,7 +211,8 @@ export const mainExtensions = [
|
||||
parentName === "tableCell" ||
|
||||
parentName === "tableHeader" ||
|
||||
parentName === "callout" ||
|
||||
parentName === "blockquote"
|
||||
parentName === "blockquote" ||
|
||||
parentName === "footnote"
|
||||
) {
|
||||
return i18n.t("Write...");
|
||||
}
|
||||
@@ -417,6 +426,9 @@ export const mainExtensions = [
|
||||
}).configure(),
|
||||
Columns,
|
||||
Column,
|
||||
Footnotes,
|
||||
Footnote,
|
||||
FootnoteReference,
|
||||
AutoJoiner.configure({
|
||||
elementsToJoin: [],
|
||||
}),
|
||||
|
||||
@@ -249,6 +249,7 @@ function CollabPageEditor({
|
||||
{
|
||||
extensions,
|
||||
editable,
|
||||
textDirection: "auto",
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
@@ -484,6 +485,7 @@ function StaticPageEditor({
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
textDirection="auto"
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
editorProps={{
|
||||
|
||||
@@ -85,6 +85,7 @@ export default function ReadonlyPageEditor({
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
textDirection="auto"
|
||||
extensions={titleExtensions}
|
||||
content={title}
|
||||
></EditorProvider>
|
||||
@@ -93,6 +94,7 @@ export default function ReadonlyPageEditor({
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
textDirection="auto"
|
||||
extensions={extensions}
|
||||
content={content}
|
||||
onCreate={({ editor }) => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
.ProseMirror sup a.footnote-ref {
|
||||
color: var(--mantine-primary-color-filled);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ProseMirror sup:has(a.footnote-ref) {
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.ProseMirror ol.footnotes {
|
||||
margin-top: 2rem;
|
||||
padding-top: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mantine-color-dimmed);
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.ProseMirror ol.footnotes:has(li) {
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.ProseMirror ol.footnotes li p {
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
@@ -1,6 +1,46 @@
|
||||
/* 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(
|
||||
|
||||
@@ -18,3 +18,4 @@
|
||||
@import "./columns.css";
|
||||
@import "./status.css";
|
||||
@import "./base-embed.css";
|
||||
@import "./footnotes.css";
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.column-resize-handle {
|
||||
@@ -86,6 +86,16 @@
|
||||
.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"] {
|
||||
|
||||
@@ -86,6 +86,7 @@ export function TitleEditor({
|
||||
},
|
||||
editable: editable,
|
||||
content: title,
|
||||
textDirection: "auto",
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
|
||||
@@ -6,4 +6,13 @@ export const activeHistoryPrevIdAtom = atom<string>("");
|
||||
export const highlightChangesAtom = atom<boolean>(true);
|
||||
|
||||
export type DiffCounts = { added: number; deleted: number; total: number };
|
||||
export const diffCountsAtom = atom<DiffCounts | null>(null);
|
||||
export const diffCountsAtom = atom<DiffCounts | null>(
|
||||
null as DiffCounts | null,
|
||||
);
|
||||
|
||||
export type ComparePair = { newerId: string; olderId: string };
|
||||
export const compareModeAtom = atom<boolean>(false);
|
||||
export const compareSelectionAtom = atom<string[]>([]);
|
||||
export const comparePairAtom = atom<ComparePair | null>(
|
||||
null as ComparePair | null,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.history {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||
|
||||
@mixin hover {
|
||||
@@ -12,6 +12,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
.historyButton {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.compareCheckbox {
|
||||
padding-left: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.itemMenu {
|
||||
opacity: 0;
|
||||
margin-right: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.history:hover .itemMenu,
|
||||
.history:focus-within .itemMenu,
|
||||
.history.active .itemMenu,
|
||||
.itemMenu[aria-expanded="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.historyEditor {
|
||||
:global(.ProseMirror) {
|
||||
padding: 0 !important;
|
||||
@@ -77,3 +99,8 @@
|
||||
flex: 1;
|
||||
padding: rem(16px) rem(40px);
|
||||
}
|
||||
|
||||
.compareBanner {
|
||||
border-bottom: rem(1px) solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export function HistoryEditor({
|
||||
const editor = useEditor({
|
||||
extensions: mainExtensions,
|
||||
editable: false,
|
||||
textDirection: "auto",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -170,7 +171,6 @@ export function HistoryEditor({
|
||||
}
|
||||
|
||||
const total = addedCount + deletedCount;
|
||||
// @ts-ignore
|
||||
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
|
||||
|
||||
editor.setOptions({
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
Text,
|
||||
Group,
|
||||
UnstyledButton,
|
||||
Avatar,
|
||||
Tooltip,
|
||||
ActionIcon,
|
||||
Checkbox,
|
||||
Menu,
|
||||
} from "@mantine/core";
|
||||
import { IconDots } from "@tabler/icons-react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import classes from "./css/history.module.css";
|
||||
import clsx from "clsx";
|
||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
import { memo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const MAX_VISIBLE_AVATARS = 5;
|
||||
|
||||
@@ -15,6 +26,13 @@ interface HistoryItemProps {
|
||||
onHover?: (id: string, index: number) => void;
|
||||
onHoverEnd?: () => void;
|
||||
isActive: boolean;
|
||||
compareMode: boolean;
|
||||
isChecked: boolean;
|
||||
isCheckboxDisabled: boolean;
|
||||
canCompare: boolean;
|
||||
onToggleCompare: (id: string) => void;
|
||||
onStartCompare: (id: string) => void;
|
||||
onRestore?: (id: string, index: number) => void;
|
||||
}
|
||||
|
||||
const HistoryItem = memo(function HistoryItem({
|
||||
@@ -24,10 +42,24 @@ const HistoryItem = memo(function HistoryItem({
|
||||
onHover,
|
||||
onHoverEnd,
|
||||
isActive,
|
||||
compareMode,
|
||||
isChecked,
|
||||
isCheckboxDisabled,
|
||||
canCompare,
|
||||
onToggleCompare,
|
||||
onStartCompare,
|
||||
onRestore,
|
||||
}: HistoryItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const date = formattedDate(new Date(historyItem.createdAt));
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(historyItem.id, index);
|
||||
}, [onSelect, historyItem.id, index]);
|
||||
if (compareMode) {
|
||||
onToggleCompare(historyItem.id);
|
||||
} else {
|
||||
onSelect(historyItem.id, index);
|
||||
}
|
||||
}, [compareMode, onToggleCompare, onSelect, historyItem.id, index]);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
onHover?.(historyItem.id, index);
|
||||
@@ -37,63 +69,115 @@ const HistoryItem = memo(function HistoryItem({
|
||||
const hasContributors = contributors && contributors.length > 0;
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
p="xs"
|
||||
onClick={handleClick}
|
||||
<div
|
||||
className={clsx(classes.history, { [classes.active]: isActive })}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={onHoverEnd}
|
||||
className={clsx(classes.history, { [classes.active]: isActive })}
|
||||
>
|
||||
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
|
||||
{compareMode && (
|
||||
<Checkbox
|
||||
size="xs"
|
||||
className={classes.compareCheckbox}
|
||||
checked={isChecked}
|
||||
disabled={isCheckboxDisabled}
|
||||
onChange={() => onToggleCompare(historyItem.id)}
|
||||
aria-label={t("Select version from {{date}}", { date })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group gap={6} wrap="nowrap" mt={4}>
|
||||
{hasContributors ? (
|
||||
<>
|
||||
<Tooltip.Group openDelay={300} closeDelay={100}>
|
||||
<Avatar.Group spacing={8}>
|
||||
{contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
|
||||
<Tooltip key={contributor.id} label={contributor.name} withArrow>
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={contributor.avatarUrl}
|
||||
name={contributor.name}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
{contributors.length > MAX_VISIBLE_AVATARS && (
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => (
|
||||
<div key={c.id}>{c.name}</div>
|
||||
<UnstyledButton
|
||||
p="xs"
|
||||
onClick={handleClick}
|
||||
className={classes.historyButton}
|
||||
>
|
||||
<Text size="sm">{date}</Text>
|
||||
|
||||
<Group gap={6} wrap="nowrap" mt={4}>
|
||||
{hasContributors ? (
|
||||
<>
|
||||
<Tooltip.Group openDelay={300} closeDelay={100}>
|
||||
<Avatar.Group spacing={8}>
|
||||
{contributors
|
||||
.slice(0, MAX_VISIBLE_AVATARS)
|
||||
.map((contributor) => (
|
||||
<Tooltip
|
||||
key={contributor.id}
|
||||
label={contributor.name}
|
||||
withArrow
|
||||
>
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={contributor.avatarUrl}
|
||||
name={contributor.name}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
>
|
||||
<Avatar size="sm" color="gray">
|
||||
+{contributors.length - MAX_VISIBLE_AVATARS}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Avatar.Group>
|
||||
</Tooltip.Group>
|
||||
{contributors.length === 1 && (
|
||||
{contributors.length > MAX_VISIBLE_AVATARS && (
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={contributors
|
||||
.slice(MAX_VISIBLE_AVATARS)
|
||||
.map((c) => (
|
||||
<div key={c.id}>{c.name}</div>
|
||||
))}
|
||||
>
|
||||
<Avatar size="sm" color="gray">
|
||||
+{contributors.length - MAX_VISIBLE_AVATARS}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Avatar.Group>
|
||||
</Tooltip.Group>
|
||||
{contributors.length === 1 && (
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{contributors[0].name}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
|
||||
name={historyItem.lastUpdatedBy?.name}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{contributors[0].name}
|
||||
{historyItem.lastUpdatedBy?.name}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
{!compareMode && (
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
className={classes.itemMenu}
|
||||
aria-label={t("Version actions for {{date}}", { date })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconDots size={18} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
disabled={!canCompare}
|
||||
onClick={() => onStartCompare(historyItem.id)}
|
||||
>
|
||||
{t("Compare")}
|
||||
</Menu.Item>
|
||||
{onRestore && (
|
||||
<Menu.Item onClick={() => onRestore(historyItem.id, index)}>
|
||||
{t("Restore")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
|
||||
name={historyItem.lastUpdatedBy?.name}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{historyItem.lastUpdatedBy?.name}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,8 +6,12 @@ import HistoryItem from "@/features/page-history/components/history-item";
|
||||
import {
|
||||
activeHistoryIdAtom,
|
||||
activeHistoryPrevIdAtom,
|
||||
compareModeAtom,
|
||||
comparePairAtom,
|
||||
compareSelectionAtom,
|
||||
historyAtoms,
|
||||
} from "@/features/page-history/atoms/history-atoms";
|
||||
import { resolveComparePair } from "@/features/page-history/utils/resolve-compare-pair";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
@@ -32,6 +36,9 @@ function HistoryList({ pageId }: Props) {
|
||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
|
||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||
const [compareMode, setCompareMode] = useAtom(compareModeAtom);
|
||||
const [compareSelection, setCompareSelection] = useAtom(compareSelectionAtom);
|
||||
const setComparePair = useSetAtom(comparePairAtom);
|
||||
|
||||
const {
|
||||
data: pageHistoryData,
|
||||
@@ -79,10 +86,58 @@ function HistoryList({ pageId }: Props) {
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string, index: number) => {
|
||||
setComparePair(null);
|
||||
setActiveHistoryId(id);
|
||||
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
|
||||
},
|
||||
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
||||
[historyItems, setActiveHistoryId, setActiveHistoryPrevId, setComparePair],
|
||||
);
|
||||
|
||||
const handleToggleCompare = useCallback(
|
||||
(id: string) => {
|
||||
setCompareSelection((prev) => {
|
||||
if (prev.includes(id)) return prev.filter((item) => item !== id);
|
||||
if (prev.length >= 2) return prev;
|
||||
return [...prev, id];
|
||||
});
|
||||
},
|
||||
[setCompareSelection],
|
||||
);
|
||||
|
||||
const handleStartCompare = useCallback(
|
||||
(id: string) => {
|
||||
setComparePair(null);
|
||||
setCompareMode(true);
|
||||
setCompareSelection([id]);
|
||||
},
|
||||
[setComparePair, setCompareMode, setCompareSelection],
|
||||
);
|
||||
|
||||
const handleCancelCompare = useCallback(() => {
|
||||
setCompareMode(false);
|
||||
setCompareSelection([]);
|
||||
}, [setCompareMode, setCompareSelection]);
|
||||
|
||||
const handleConfirmCompare = useCallback(() => {
|
||||
const pair = resolveComparePair(historyItems, compareSelection);
|
||||
if (!pair) return;
|
||||
setComparePair(pair);
|
||||
setCompareMode(false);
|
||||
setCompareSelection([]);
|
||||
}, [
|
||||
historyItems,
|
||||
compareSelection,
|
||||
setComparePair,
|
||||
setCompareMode,
|
||||
setCompareSelection,
|
||||
]);
|
||||
|
||||
const handleRestoreItem = useCallback(
|
||||
(id: string, index: number) => {
|
||||
handleSelect(id, index);
|
||||
confirmRestore(id);
|
||||
},
|
||||
[handleSelect, confirmRestore],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -138,6 +193,16 @@ function HistoryList({ pageId }: Props) {
|
||||
onHover={handleHover}
|
||||
onHoverEnd={clearPrefetchTimeout}
|
||||
isActive={historyItem.id === activeHistoryId}
|
||||
compareMode={compareMode}
|
||||
isChecked={compareSelection.includes(historyItem.id)}
|
||||
isCheckboxDisabled={
|
||||
!compareSelection.includes(historyItem.id) &&
|
||||
compareSelection.length >= 2
|
||||
}
|
||||
canCompare={historyItems.length >= 2}
|
||||
onToggleCompare={handleToggleCompare}
|
||||
onStartCompare={handleStartCompare}
|
||||
onRestore={canRestore ? handleRestoreItem : undefined}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
|
||||
@@ -148,22 +213,44 @@ function HistoryList({ pageId }: Props) {
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{canRestore && (
|
||||
{compareMode ? (
|
||||
<>
|
||||
<Divider />
|
||||
<Group p="xs" wrap="nowrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-md"
|
||||
onClick={() => setHistoryModalOpen(false)}
|
||||
onClick={handleCancelCompare}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="compact-md" onClick={confirmRestore}>
|
||||
{t("Restore")}
|
||||
<Button
|
||||
size="compact-md"
|
||||
disabled={compareSelection.length !== 2}
|
||||
onClick={handleConfirmCompare}
|
||||
>
|
||||
{t("Compare")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
canRestore && (
|
||||
<>
|
||||
<Divider />
|
||||
<Group p="xs" wrap="nowrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-md"
|
||||
onClick={() => setHistoryModalOpen(false)}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="compact-md" onClick={() => confirmRestore()}>
|
||||
{t("Restore")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
CloseButton,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
@@ -12,17 +13,20 @@ import { useAtom, useAtomValue } from "jotai";
|
||||
import {
|
||||
activeHistoryIdAtom,
|
||||
activeHistoryPrevIdAtom,
|
||||
comparePairAtom,
|
||||
diffCountsAtom,
|
||||
highlightChangesAtom,
|
||||
} from "@/features/page-history/atoms/history-atoms";
|
||||
import HistoryView from "@/features/page-history/components/history-view";
|
||||
import { useRef } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useDiffNavigation,
|
||||
useHistoryReset,
|
||||
} from "@/features/page-history/hooks";
|
||||
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
|
||||
interface Props {
|
||||
pageId: string;
|
||||
@@ -36,6 +40,28 @@ export default function HistoryModalBody({ pageId }: Props) {
|
||||
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
|
||||
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
|
||||
const diffCounts = useAtomValue(diffCountsAtom);
|
||||
const [comparePair, setComparePair] = useAtom(comparePairAtom);
|
||||
|
||||
const { data: pageHistoryData } = usePageHistoryListQuery(pageId);
|
||||
const historyItems = useMemo(
|
||||
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
|
||||
[pageHistoryData],
|
||||
);
|
||||
|
||||
const compareLabel = useMemo(() => {
|
||||
if (!comparePair) return null;
|
||||
const newerItem = historyItems.find(
|
||||
(item) => item.id === comparePair.newerId,
|
||||
);
|
||||
const olderItem = historyItems.find(
|
||||
(item) => item.id === comparePair.olderId,
|
||||
);
|
||||
if (!newerItem || !olderItem) return null;
|
||||
return t("Comparing {{newer}} and {{older}}", {
|
||||
newer: formattedDate(new Date(newerItem.createdAt)),
|
||||
older: formattedDate(new Date(olderItem.createdAt)),
|
||||
});
|
||||
}, [comparePair, historyItems, t]);
|
||||
|
||||
useHistoryReset(pageId);
|
||||
const { currentChangeIndex, handlePrevChange, handleNextChange } =
|
||||
@@ -50,6 +76,25 @@ export default function HistoryModalBody({ pageId }: Props) {
|
||||
</nav>
|
||||
|
||||
<div style={{ position: "relative", flex: 1 }}>
|
||||
{comparePair && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px="md"
|
||||
py={4}
|
||||
className={classes.compareBanner}
|
||||
>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{compareLabel ?? t("Compare versions")}
|
||||
</Text>
|
||||
<CloseButton
|
||||
size="sm"
|
||||
aria-label={t("Exit compare")}
|
||||
onClick={() => setComparePair(null)}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<ScrollArea
|
||||
h={650}
|
||||
w="100%"
|
||||
@@ -57,11 +102,18 @@ export default function HistoryModalBody({ pageId }: Props) {
|
||||
viewportRef={scrollViewportRef}
|
||||
>
|
||||
<div className={classes.sidebarRightSection}>
|
||||
{activeHistoryId && <HistoryView />}
|
||||
{comparePair ? (
|
||||
<HistoryView
|
||||
historyId={comparePair.newerId}
|
||||
prevHistoryId={comparePair.olderId}
|
||||
/>
|
||||
) : (
|
||||
activeHistoryId && <HistoryView />
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{activeHistoryId && activeHistoryPrevId && (
|
||||
{(comparePair || (activeHistoryId && activeHistoryPrevId)) && (
|
||||
<Paper
|
||||
shadow="md"
|
||||
radius="xl"
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
|
||||
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={confirmRestore}>{t("Restore")}</Button>
|
||||
<Button onClick={() => confirmRestore()}>{t("Restore")}</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,21 +7,29 @@ import {
|
||||
activeHistoryPrevIdAtom,
|
||||
} from "@/features/page-history/atoms/history-atoms";
|
||||
|
||||
function HistoryView() {
|
||||
interface Props {
|
||||
historyId?: string;
|
||||
prevHistoryId?: string;
|
||||
}
|
||||
|
||||
function HistoryView({ historyId, prevHistoryId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const historyId = useAtomValue(activeHistoryIdAtom);
|
||||
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
|
||||
const activeId = useAtomValue(activeHistoryIdAtom);
|
||||
const activePrevId = useAtomValue(activeHistoryPrevIdAtom);
|
||||
|
||||
const resolvedId = historyId ?? activeId;
|
||||
const resolvedPrevId = prevHistoryId ?? activePrevId;
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading: isLoadingCurrent,
|
||||
isError: isErrorCurrent,
|
||||
} = usePageHistoryQuery(historyId);
|
||||
} = usePageHistoryQuery(resolvedId);
|
||||
const {
|
||||
data: prevData,
|
||||
isLoading: isLoadingPrev,
|
||||
isError: isErrorPrev,
|
||||
} = usePageHistoryQuery(prevHistoryId);
|
||||
} = usePageHistoryQuery(resolvedPrevId);
|
||||
|
||||
if (isLoadingCurrent || isLoadingPrev) {
|
||||
return <></>;
|
||||
|
||||
@@ -3,22 +3,45 @@ import { useEffect } from "react";
|
||||
import {
|
||||
activeHistoryIdAtom,
|
||||
activeHistoryPrevIdAtom,
|
||||
compareModeAtom,
|
||||
comparePairAtom,
|
||||
compareSelectionAtom,
|
||||
diffCountsAtom,
|
||||
} from "@/features/page-history/atoms/history-atoms";
|
||||
|
||||
/**
|
||||
* Resets history state when pageId changes.
|
||||
* Clears active selection and diff counts.
|
||||
* Clears active selection, diff counts, and compare state.
|
||||
* Compare state also resets on unmount so reopening the modal starts clean.
|
||||
*/
|
||||
export function useHistoryReset(pageId: string) {
|
||||
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
|
||||
const [, setDiffCounts] = useAtom(diffCountsAtom);
|
||||
const [, setCompareMode] = useAtom(compareModeAtom);
|
||||
const [, setCompareSelection] = useAtom(compareSelectionAtom);
|
||||
const [, setComparePair] = useAtom(comparePairAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const resetCompare = () => {
|
||||
setCompareMode(false);
|
||||
setCompareSelection([]);
|
||||
setComparePair(null);
|
||||
};
|
||||
|
||||
setActiveHistoryId("");
|
||||
setActiveHistoryPrevId("");
|
||||
// @ts-ignore
|
||||
setDiffCounts(null);
|
||||
}, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]);
|
||||
resetCompare();
|
||||
|
||||
return resetCompare;
|
||||
}, [
|
||||
pageId,
|
||||
setActiveHistoryId,
|
||||
setActiveHistoryPrevId,
|
||||
setDiffCounts,
|
||||
setCompareMode,
|
||||
setCompareSelection,
|
||||
setComparePair,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "@mantine/core";
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
activeHistoryIdAtom,
|
||||
historyAtoms,
|
||||
} from "@/features/page-history/atoms/history-atoms";
|
||||
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
|
||||
import { fetchPageHistory } from "@/features/page-history/queries/page-history-query";
|
||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
@@ -25,8 +26,6 @@ export function useHistoryRestore() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
|
||||
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
|
||||
|
||||
const mainEditor = useAtomValue(pageEditorAtom);
|
||||
const mainEditorTitle = useAtomValue(titleEditorAtom);
|
||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||
@@ -40,47 +39,66 @@ export function useHistoryRestore() {
|
||||
SpaceCaslSubject.Page,
|
||||
);
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
if (!activeHistoryData) return;
|
||||
if (
|
||||
!mainEditor ||
|
||||
mainEditor.isDestroyed ||
|
||||
!mainEditorTitle ||
|
||||
mainEditorTitle.isDestroyed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const handleRestore = useCallback(
|
||||
async (historyId: string) => {
|
||||
let historyData: IPageHistory;
|
||||
try {
|
||||
historyData = await fetchPageHistory(historyId);
|
||||
} catch {
|
||||
notifications.show({
|
||||
message: t("Error fetching page data."),
|
||||
color: "red",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
mainEditorTitle
|
||||
.chain()
|
||||
.clearContent()
|
||||
.setContent(activeHistoryData.title, { emitUpdate: true })
|
||||
.run();
|
||||
if (
|
||||
!mainEditor ||
|
||||
mainEditor.isDestroyed ||
|
||||
!mainEditorTitle ||
|
||||
mainEditorTitle.isDestroyed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainEditor
|
||||
.chain()
|
||||
.clearContent()
|
||||
.setContent(activeHistoryData.content)
|
||||
.run();
|
||||
mainEditorTitle
|
||||
.chain()
|
||||
.clearContent()
|
||||
.setContent(historyData.title, { emitUpdate: true })
|
||||
.run();
|
||||
|
||||
setHistoryModalOpen(false);
|
||||
notifications.show({ message: t("Successfully restored") });
|
||||
}, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]);
|
||||
mainEditor
|
||||
.chain()
|
||||
.clearContent()
|
||||
.setContent(historyData.content)
|
||||
.run();
|
||||
|
||||
const confirmRestore = useCallback(() => {
|
||||
modals.openConfirmModal({
|
||||
title: t("Please confirm your action"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
||||
onConfirm: handleRestore,
|
||||
});
|
||||
}, [t, handleRestore]);
|
||||
setHistoryModalOpen(false);
|
||||
notifications.show({ message: t("Successfully restored") });
|
||||
},
|
||||
[mainEditor, mainEditorTitle, setHistoryModalOpen, t],
|
||||
);
|
||||
|
||||
const confirmRestore = useCallback(
|
||||
(historyId?: string) => {
|
||||
const targetId = historyId ?? activeHistoryId;
|
||||
if (!targetId) return;
|
||||
|
||||
modals.openConfirmModal({
|
||||
title: t("Please confirm your action"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
||||
onConfirm: () => handleRestore(targetId),
|
||||
});
|
||||
},
|
||||
[t, handleRestore, activeHistoryId],
|
||||
);
|
||||
|
||||
return { canRestore, confirmRestore };
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ export function prefetchPageHistory(historyId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchPageHistory(historyId: string): Promise<IPageHistory> {
|
||||
return queryClient.fetchQuery({
|
||||
queryKey: ["page-history", historyId],
|
||||
queryFn: () => getPageHistoryById(historyId),
|
||||
staleTime: HISTORY_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePageHistoryListQuery(
|
||||
pageId: string,
|
||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveComparePair } from "./resolve-compare-pair";
|
||||
|
||||
// list is newest-first, matching usePageHistoryListQuery order
|
||||
const items = [{ id: "v3" }, { id: "v2" }, { id: "v1" }];
|
||||
|
||||
describe("resolveComparePair", () => {
|
||||
it("orders newer before older regardless of selection order", () => {
|
||||
expect(resolveComparePair(items, ["v1", "v3"])).toEqual({
|
||||
newerId: "v3",
|
||||
olderId: "v1",
|
||||
});
|
||||
expect(resolveComparePair(items, ["v3", "v1"])).toEqual({
|
||||
newerId: "v3",
|
||||
olderId: "v1",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null unless exactly two versions are selected", () => {
|
||||
expect(resolveComparePair(items, [])).toBeNull();
|
||||
expect(resolveComparePair(items, ["v1"])).toBeNull();
|
||||
expect(resolveComparePair(items, ["v1", "v2", "v3"])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when a selected id is not in the list", () => {
|
||||
expect(resolveComparePair(items, ["v1", "missing"])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the same id is selected twice", () => {
|
||||
expect(resolveComparePair(items, ["v2", "v2"])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ComparePair } from "@/features/page-history/atoms/history-atoms";
|
||||
|
||||
/**
|
||||
* Resolves which of the two selected versions is newer using their position
|
||||
* in the history list (list is newest-first: lower index = newer).
|
||||
*/
|
||||
export function resolveComparePair(
|
||||
historyItems: { id: string }[],
|
||||
selection: string[],
|
||||
): ComparePair | null {
|
||||
if (selection.length !== 2) return null;
|
||||
const indexA = historyItems.findIndex((item) => item.id === selection[0]);
|
||||
const indexB = historyItems.findIndex((item) => item.id === selection[1]);
|
||||
if (indexA === -1 || indexB === -1 || indexA === indexB) return null;
|
||||
return indexA < indexB
|
||||
? { newerId: selection[0], olderId: selection[1] }
|
||||
: { newerId: selection[1], olderId: selection[0] };
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IconList,
|
||||
IconMarkdown,
|
||||
IconMessage,
|
||||
IconPaperclip,
|
||||
IconPrinter,
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
import { formattedDate } from "@/lib/time.ts";
|
||||
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import PageAttachmentsModal from "@/features/attachments/components/page-attachments-modal.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { PageShareModal } from "@/ee/page-permission";
|
||||
import {
|
||||
@@ -157,6 +159,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
verificationOpened,
|
||||
{ open: openVerificationModal, close: closeVerificationModal },
|
||||
] = useDisclosure(false);
|
||||
const [
|
||||
attachmentsOpened,
|
||||
{ open: openAttachmentsModal, close: closeAttachmentsModal },
|
||||
] = useDisclosure(false);
|
||||
const [pageEditor] = useAtom(pageEditorAtom);
|
||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
||||
const favoriteIds = useFavoriteIds("page", page?.spaceId);
|
||||
@@ -293,6 +299,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{!page?.isBase && (
|
||||
<Menu.Item
|
||||
leftSection={<IconPaperclip size={16} />}
|
||||
onClick={openAttachmentsModal}
|
||||
>
|
||||
{t("Attachments")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{!readOnly && !page?.isBase && (
|
||||
<PageVerificationMenuItem
|
||||
pageId={page?.id}
|
||||
@@ -395,6 +410,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
opened={verificationOpened}
|
||||
onClose={closeVerificationModal}
|
||||
/>
|
||||
|
||||
<PageAttachmentsModal
|
||||
pageId={page.id}
|
||||
open={attachmentsOpened}
|
||||
onClose={closeAttachmentsModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { Group, Select, SelectProps, Text } from "@mantine/core";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -14,6 +14,7 @@ interface SpaceSelectProps {
|
||||
width?: number;
|
||||
opened?: boolean;
|
||||
clearable?: boolean;
|
||||
withinPortal?: boolean;
|
||||
}
|
||||
|
||||
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
|
||||
@@ -41,6 +42,7 @@ export function SpaceSelect({
|
||||
width,
|
||||
opened,
|
||||
clearable,
|
||||
withinPortal = true,
|
||||
}: SpaceSelectProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
@@ -50,9 +52,13 @@ export function SpaceSelect({
|
||||
limit: 50,
|
||||
});
|
||||
const [data, setData] = useState([]);
|
||||
const fetchedSpaces = useRef(new Map<string, ISpace>());
|
||||
|
||||
useEffect(() => {
|
||||
if (spaces) {
|
||||
spaces.items.forEach((space: ISpace) =>
|
||||
fetchedSpaces.current.set(space.slug, space),
|
||||
);
|
||||
const spaceData = spaces?.items
|
||||
.filter((space: ISpace) => space.slug !== value)
|
||||
.map((space: ISpace) => {
|
||||
@@ -83,14 +89,19 @@ export function SpaceSelect({
|
||||
onSearchChange={setSearchValue}
|
||||
clearable={clearable}
|
||||
variant="filled"
|
||||
onChange={(slug) =>
|
||||
onChange(spaces.items?.find((item) => item.slug === slug))
|
||||
}
|
||||
onChange={(slug) => {
|
||||
// options accumulate across fetches; resolve against everything
|
||||
// fetched, not just the latest query result
|
||||
const space = slug && fetchedSpaces.current.get(slug);
|
||||
if (space) {
|
||||
onChange(space);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
nothingFoundMessage={t("No space found")}
|
||||
limit={50}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ width, withinPortal: true, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
|
||||
comboboxProps={{ width, withinPortal, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
|
||||
dropdownOpened={opened}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -70,6 +70,7 @@ export function SwitchSpace({
|
||||
onChange={(space) => handleSelect(space.slug)}
|
||||
width={300}
|
||||
opened={true}
|
||||
withinPortal={false}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -43,6 +43,10 @@ export function isCloud(): boolean {
|
||||
return castToBoolean(getConfigValue("CLOUD"));
|
||||
}
|
||||
|
||||
export function isBetaConfluenceImporter(): boolean {
|
||||
return castToBoolean(getConfigValue("BETA_CONFLUENCE_IMPORTER"));
|
||||
}
|
||||
|
||||
export function getAvatarUrl(
|
||||
avatarUrl: string,
|
||||
type: AvatarIconType = AvatarIconType.AVATAR,
|
||||
|
||||
@@ -16,6 +16,7 @@ export default defineConfig(({ mode }) => {
|
||||
BILLING_TRIAL_DAYS,
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_KEY,
|
||||
BETA_CONFLUENCE_IMPORTER,
|
||||
} = loadEnv(mode, envPath, "");
|
||||
|
||||
return {
|
||||
@@ -31,6 +32,7 @@ export default defineConfig(({ mode }) => {
|
||||
BILLING_TRIAL_DAYS,
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_KEY,
|
||||
BETA_CONFLUENCE_IMPORTER
|
||||
},
|
||||
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
||||
},
|
||||
@@ -55,6 +57,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
server: {
|
||||
allowedHosts: ['docmost.nz'],
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: APP_URL,
|
||||
|
||||
@@ -51,19 +51,19 @@
|
||||
"@nestjs-labs/nestjs-ioredis": "11.0.4",
|
||||
"@nestjs/bullmq": "11.0.4",
|
||||
"@nestjs/cache-manager": "3.1.3",
|
||||
"@nestjs/common": "11.1.27",
|
||||
"@nestjs/common": "11.1.28",
|
||||
"@nestjs/config": "4.0.4",
|
||||
"@nestjs/core": "11.1.27",
|
||||
"@nestjs/event-emitter": "3.1.0",
|
||||
"@nestjs/jwt": "11.0.2",
|
||||
"@nestjs/mapped-types": "2.1.1",
|
||||
"@nestjs/passport": "11.0.5",
|
||||
"@nestjs/platform-fastify": "11.1.27",
|
||||
"@nestjs/platform-socket.io": "11.1.27",
|
||||
"@nestjs/platform-fastify": "11.1.28",
|
||||
"@nestjs/platform-socket.io": "11.1.28",
|
||||
"@nestjs/schedule": "6.1.3",
|
||||
"@nestjs/terminus": "11.1.1",
|
||||
"@nestjs/throttler": "6.5.0",
|
||||
"@nestjs/websockets": "11.1.27",
|
||||
"@nestjs/websockets": "11.1.28",
|
||||
"@node-saml/passport-saml": "5.1.0",
|
||||
"@socket.io/redis-adapter": "8.3.0",
|
||||
"ai": "6.0.134",
|
||||
@@ -77,6 +77,7 @@
|
||||
"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",
|
||||
@@ -90,8 +91,8 @@
|
||||
"ldapts": "8.1.7",
|
||||
"mammoth": "1.12.0",
|
||||
"mime-types": "3.0.2",
|
||||
"msgpackr": "^1.11.9",
|
||||
"nanoid": "5.1.7",
|
||||
"msgpackr": "1.11.9",
|
||||
"nanoid": "5.1.16",
|
||||
"nestjs-cls": "6.2.0",
|
||||
"nestjs-kysely": "3.1.2",
|
||||
"nestjs-pino": "4.6.1",
|
||||
@@ -102,7 +103,7 @@
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.1",
|
||||
"pg-tsquery": "8.4.2",
|
||||
"pgvector": "^0.2.1",
|
||||
"pgvector": "0.2.1",
|
||||
"pino-http": "11.0.0",
|
||||
"pino-pretty": "13.1.3",
|
||||
"postgres": "3.4.8",
|
||||
|
||||
@@ -17,6 +17,7 @@ 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';
|
||||
@@ -27,6 +28,7 @@ import { LoggerModule } from './common/logger/logger.module';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||
import { EncryptionModule } from './integrations/encryption/encryption.module';
|
||||
|
||||
const enterpriseModules = [];
|
||||
try {
|
||||
@@ -53,6 +55,7 @@ try {
|
||||
CoreModule,
|
||||
DatabaseModule,
|
||||
EnvironmentModule,
|
||||
EncryptionModule,
|
||||
RedisModule.forRootAsync({
|
||||
useClass: RedisConfigService,
|
||||
}),
|
||||
@@ -74,6 +77,7 @@ try {
|
||||
StaticModule,
|
||||
HealthModule,
|
||||
ImportModule,
|
||||
ImportProcessorModule,
|
||||
ExportModule,
|
||||
StorageModule.forRootAsync({
|
||||
imports: [EnvironmentModule],
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import { Document } from '@tiptap/extension-document';
|
||||
import { TextAlign } from '@tiptap/extension-text-align';
|
||||
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,6 +35,7 @@ import {
|
||||
Mention,
|
||||
Subpages,
|
||||
Highlight,
|
||||
Color,
|
||||
Indent,
|
||||
UniqueID,
|
||||
Columns,
|
||||
@@ -45,8 +46,16 @@ import {
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
BaseEmbed,
|
||||
Footnotes,
|
||||
Footnote,
|
||||
FootnoteReference,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import {
|
||||
extensions as coreExtensions,
|
||||
generateText,
|
||||
getSchema,
|
||||
JSONContent,
|
||||
} from '@tiptap/core';
|
||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
||||
@@ -57,12 +66,17 @@ import * as Y from 'yjs';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
export const tiptapExtensions = [
|
||||
coreExtensions.TextDirection.configure({ direction: 'auto' }),
|
||||
StarterKit.configure({
|
||||
document: false,
|
||||
codeBlock: false,
|
||||
link: false,
|
||||
trailingNode: false,
|
||||
heading: false,
|
||||
}),
|
||||
Document.extend({
|
||||
content: 'block+ footnotes?',
|
||||
}),
|
||||
Heading,
|
||||
UniqueID.configure({
|
||||
types: ['heading', 'paragraph', 'transclusionSource'],
|
||||
@@ -110,7 +124,10 @@ export const tiptapExtensions = [
|
||||
Status,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
BaseEmbed
|
||||
BaseEmbed,
|
||||
Footnotes,
|
||||
Footnote,
|
||||
FootnoteReference,
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: any) {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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,8 +53,14 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
|
||||
import { TokenService } from '../auth/services/token.service';
|
||||
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
||||
import * as path from 'path';
|
||||
import { AttachmentInfoDto, RemoveIconDto } from './dto/attachment.dto';
|
||||
import {
|
||||
AttachmentInfoDto,
|
||||
PageIdDto,
|
||||
RemoveIconDto,
|
||||
} from './dto/attachment.dto';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||
import { DomainService } from '../../integrations/environment/domain.service';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
@@ -75,6 +81,7 @@ export class AttachmentController {
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly domainService: DomainService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@@ -151,7 +158,10 @@ export class AttachmentController {
|
||||
},
|
||||
});
|
||||
|
||||
return res.send(fileResponse);
|
||||
return res.send({
|
||||
...fileResponse,
|
||||
url: this.buildFileUrl(workspace, fileResponse),
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err?.statusCode === 413) {
|
||||
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
|
||||
@@ -411,7 +421,37 @@ export class AttachmentController {
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return attachment;
|
||||
return { ...attachment, url: this.buildFileUrl(workspace, attachment) };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('pages/attachments')
|
||||
async getPageAttachments(
|
||||
@Body() dto: PageIdDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page || page.workspaceId !== workspace.id) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
const result = await this.attachmentRepo.findPageAttachments(
|
||||
page.id,
|
||||
pagination,
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map((attachment) => ({
|
||||
...attachment,
|
||||
url: this.buildFileUrl(workspace, attachment),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -465,6 +505,10 @@ export class AttachmentController {
|
||||
}
|
||||
}
|
||||
|
||||
private buildFileUrl(workspace: Workspace, attachment: Attachment): string {
|
||||
return `${this.domainService.getUrl(workspace.hostname)}/api/files/${attachment.id}/${encodeURIComponent(attachment.fileName)}`;
|
||||
}
|
||||
|
||||
private async sendFileResponse(
|
||||
req: FastifyRequest,
|
||||
res: FastifyReply,
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
|
||||
import {
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import { AttachmentType } from '../attachment.constants';
|
||||
|
||||
export class AttachmentInfoDto {
|
||||
@@ -7,6 +14,12 @@ export class AttachmentInfoDto {
|
||||
attachmentId: string;
|
||||
}
|
||||
|
||||
export class PageIdDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export class RemoveIconDto {
|
||||
@IsEnum(AttachmentType)
|
||||
@IsIn([
|
||||
|
||||
@@ -175,44 +175,7 @@ export class PageService {
|
||||
}
|
||||
|
||||
async nextPagePosition(spaceId: string, parentPageId?: string) {
|
||||
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;
|
||||
return this.pageRepo.nextPagePosition(spaceId, parentPageId);
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -810,6 +773,10 @@ export class PageService {
|
||||
throw new BadRequestException('Invalid move position');
|
||||
}
|
||||
|
||||
if (dto.parentPageId && dto.parentPageId === dto.pageId) {
|
||||
throw new BadRequestException('A page cannot be its own parent');
|
||||
}
|
||||
|
||||
let parentPageId = null;
|
||||
if (movedPage.parentPageId === dto.parentPageId) {
|
||||
parentPageId = undefined;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import {
|
||||
@@ -8,6 +11,8 @@ import {
|
||||
UpdatableAttachment,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { AttachmentType } from '../../../core/attachment/attachment.constants';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentRepo {
|
||||
@@ -89,6 +94,41 @@ export class AttachmentRepo {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findPageAttachments(pageId: string, pagination: PaginationOptions) {
|
||||
let query = this.db
|
||||
.selectFrom('attachments')
|
||||
.select(this.baseFields)
|
||||
.select((eb) => this.withCreator(eb))
|
||||
.where('pageId', '=', pageId)
|
||||
.where('type', '=', AttachmentType.File)
|
||||
.where('deletedAt', 'is', null);
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where(
|
||||
sql`f_unaccent(file_name)`,
|
||||
'ilike',
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
);
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [{ expression: 'id', direction: 'desc' }],
|
||||
parseCursor: (cursor) => ({ id: cursor.id }),
|
||||
});
|
||||
}
|
||||
|
||||
withCreator(eb: ExpressionBuilder<DB, 'attachments'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('users')
|
||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
||||
.whereRef('users.id', '=', 'attachments.creatorId'),
|
||||
).as('creator');
|
||||
}
|
||||
|
||||
async findByIds(
|
||||
ids: string[],
|
||||
opts?: {
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 {
|
||||
@@ -605,4 +606,27 @@ export class PageRepo {
|
||||
.execute()
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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,6 +1,8 @@
|
||||
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;
|
||||
}
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 05529bcf97...3a5ead292e
@@ -0,0 +1,13 @@
|
||||
export class UnableToInitialize extends Error {
|
||||
constructor(message: string) {
|
||||
super(`Unable to initialize the encryption service: ${message}`);
|
||||
this.name = 'UnableToInitialize';
|
||||
}
|
||||
}
|
||||
|
||||
export class UnableToDecrypt extends Error {
|
||||
constructor(reason: string) {
|
||||
super(`Unable to decrypt the ciphertext: ${reason}`);
|
||||
this.name = 'UnableToDecrypt';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { EncryptionService } from './encryption.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EncryptionService],
|
||||
exports: [EncryptionService],
|
||||
})
|
||||
export class EncryptionModule {}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { EncryptionService } from './encryption.service';
|
||||
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
|
||||
const APP_SECRET = 'test-app-secret-with-plenty-of-entropy-1234567890';
|
||||
|
||||
const buildService = (appSecret: string | undefined) => {
|
||||
const env = { getAppSecret: () => appSecret } as EnvironmentService;
|
||||
return new EncryptionService(env);
|
||||
};
|
||||
|
||||
const decodeEnvelope = (encrypted: string) =>
|
||||
JSON.parse(Buffer.from(encrypted, 'base64').toString()) as {
|
||||
iv: string;
|
||||
authTag: string;
|
||||
cipherText: string;
|
||||
};
|
||||
|
||||
const encodeEnvelope = (envelope: {
|
||||
iv: string;
|
||||
authTag: string;
|
||||
cipherText: string;
|
||||
}) => Buffer.from(JSON.stringify(envelope)).toString('base64');
|
||||
|
||||
describe('EncryptionService', () => {
|
||||
let service: EncryptionService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EncryptionService,
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: { getAppSecret: () => APP_SECRET },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<EncryptionService>(EncryptionService);
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('compiles via Nest DI', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws UnableToInitialize when APP_SECRET is missing', () => {
|
||||
expect(() => buildService(undefined)).toThrow(UnableToInitialize);
|
||||
expect(() => buildService('')).toThrow(UnableToInitialize);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypt + decrypt round-trip', () => {
|
||||
it('decrypts back to the original plaintext', () => {
|
||||
const plaintext = 'hello world';
|
||||
const encrypted = service.encrypt(plaintext);
|
||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
const encrypted = service.encrypt('');
|
||||
expect(service.decrypt(encrypted)).toBe('');
|
||||
});
|
||||
|
||||
it('handles unicode (multi-byte UTF-8)', () => {
|
||||
const plaintext = 'héllo 🔐 世界';
|
||||
const encrypted = service.encrypt(plaintext);
|
||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('handles long plaintext (>1 block)', () => {
|
||||
const plaintext = 'a'.repeat(10_000);
|
||||
const encrypted = service.encrypt(plaintext);
|
||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('produces distinct ciphertexts for the same plaintext (random IV)', () => {
|
||||
const plaintext = 'same input';
|
||||
const a = service.encrypt(plaintext);
|
||||
const b = service.encrypt(plaintext);
|
||||
expect(a).not.toBe(b);
|
||||
expect(service.decrypt(a)).toBe(plaintext);
|
||||
expect(service.decrypt(b)).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-key isolation', () => {
|
||||
it('cannot decrypt ciphertext produced under a different APP_SECRET', () => {
|
||||
const other = buildService('totally-different-secret-value-9876543210');
|
||||
const encrypted = service.encrypt('secret');
|
||||
expect(() => other.decrypt(encrypted)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tamper detection', () => {
|
||||
it('rejects modified ciphertext', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const tamperedCipher = Buffer.from(env.cipherText, 'base64');
|
||||
tamperedCipher[0] ^= 0x01;
|
||||
const tampered = encodeEnvelope({
|
||||
...env,
|
||||
cipherText: tamperedCipher.toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects modified auth tag', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const tamperedTag = Buffer.from(env.authTag, 'base64');
|
||||
tamperedTag[0] ^= 0x01;
|
||||
const tampered = encodeEnvelope({
|
||||
...env,
|
||||
authTag: tamperedTag.toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects modified IV', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const tamperedIV = Buffer.from(env.iv, 'base64');
|
||||
tamperedIV[0] ^= 0x01;
|
||||
const tampered = encodeEnvelope({
|
||||
...env,
|
||||
iv: tamperedIV.toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed payloads', () => {
|
||||
it('rejects non-base64 garbage', () => {
|
||||
expect(() => service.decrypt('!!!not-valid-base64!!!')).toThrow(
|
||||
UnableToDecrypt,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects base64 of non-JSON', () => {
|
||||
const garbage = Buffer.from('not json at all').toString('base64');
|
||||
expect(() => service.decrypt(garbage)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects JSON missing required fields', () => {
|
||||
const partial = encodeEnvelope({
|
||||
iv: Buffer.alloc(12).toString('base64'),
|
||||
authTag: Buffer.alloc(16).toString('base64'),
|
||||
} as never);
|
||||
expect(() => service.decrypt(partial)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects wrong-length IV', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const bad = encodeEnvelope({
|
||||
...env,
|
||||
iv: Buffer.alloc(8).toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects wrong-length auth tag', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const bad = encodeEnvelope({
|
||||
...env,
|
||||
authTag: Buffer.alloc(8).toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('envelope format', () => {
|
||||
it('returns base64 of JSON envelope with iv (12B), authTag (16B), cipherText', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
expect(Buffer.from(env.iv, 'base64')).toHaveLength(12);
|
||||
expect(Buffer.from(env.authTag, 'base64')).toHaveLength(16);
|
||||
expect(Buffer.from(env.cipherText, 'base64').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// https://github.com/nhedger/nestjs-encryption - MIT
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from 'node:crypto';
|
||||
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const KEY_DOMAIN = 'docmost:encryption:v1';
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
type AEADPayload<TFormat = string | Buffer> = {
|
||||
iv: TFormat;
|
||||
authTag: TFormat;
|
||||
cipherText: TFormat;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class EncryptionService {
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(environmentService: EnvironmentService) {
|
||||
const appSecret = environmentService.getAppSecret();
|
||||
if (!appSecret) {
|
||||
throw new UnableToInitialize('APP_SECRET is not set.');
|
||||
}
|
||||
this.key = createHash('sha256')
|
||||
.update(KEY_DOMAIN)
|
||||
.update(appSecret)
|
||||
.digest();
|
||||
}
|
||||
|
||||
public encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, this.key, iv);
|
||||
const cipherText = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
const aead: AEADPayload<string> = {
|
||||
iv: iv.toString('base64'),
|
||||
authTag: authTag.toString('base64'),
|
||||
cipherText: cipherText.toString('base64'),
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(aead)).toString('base64');
|
||||
}
|
||||
|
||||
public decrypt(encrypted: string): string {
|
||||
try {
|
||||
const { iv, authTag, cipherText } = this.decodeAEADPayload(encrypted);
|
||||
const decipher = createDecipheriv(ALGORITHM, this.key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(cipherText),
|
||||
decipher.final(),
|
||||
]);
|
||||
return decrypted.toString('utf8');
|
||||
} catch (e: unknown) {
|
||||
throw new UnableToDecrypt((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private decodeAEADPayload(encodedPayload: string): AEADPayload<Buffer> {
|
||||
const payload = Buffer.from(encodedPayload, 'base64');
|
||||
|
||||
let deserializedPkg: Record<string, unknown>;
|
||||
try {
|
||||
deserializedPkg = JSON.parse(payload.toString());
|
||||
} catch {
|
||||
throw new Error('The decoded AEAD payload is not a valid JSON string.');
|
||||
}
|
||||
|
||||
for (const field of ['iv', 'authTag', 'cipherText']) {
|
||||
if (!Object.prototype.hasOwnProperty.call(deserializedPkg, field)) {
|
||||
throw new Error(`The AEAD payload is missing the ${field} field.`);
|
||||
}
|
||||
}
|
||||
|
||||
const iv = Buffer.from(deserializedPkg.iv as string, 'base64');
|
||||
if (iv.length !== IV_LENGTH) {
|
||||
throw new Error(
|
||||
`The decoded IV is not the correct length. Expected ${IV_LENGTH} bytes, got ${iv.length} bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const authTag = Buffer.from(deserializedPkg.authTag as string, 'base64');
|
||||
if (authTag.length !== AUTH_TAG_LENGTH) {
|
||||
throw new Error(
|
||||
`The decoded auth tag is not the correct length. Expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length} bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const cipherText = Buffer.from(
|
||||
deserializedPkg.cipherText as string,
|
||||
'base64',
|
||||
);
|
||||
|
||||
return { iv, authTag, cipherText };
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,13 @@ 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');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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,19 +3,13 @@ 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,
|
||||
FileTaskProcessor,
|
||||
ImportAttachmentService,
|
||||
],
|
||||
exports: [ImportService, ImportAttachmentService],
|
||||
providers: [ImportService, FileImportTaskService, ImportAttachmentService],
|
||||
exports: [ImportService, ImportAttachmentService, FileImportTaskService],
|
||||
controllers: [ImportController, FileTaskController],
|
||||
imports: [StorageModule, PageModule],
|
||||
})
|
||||
|
||||
@@ -28,6 +28,9 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
case QueueJob.IMPORT_TASK:
|
||||
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
|
||||
break;
|
||||
case QueueJob.CONFLUENCE_API_IMPORT:
|
||||
await this.processConfluenceApiImport(job.data.fileTaskId);
|
||||
break;
|
||||
case QueueJob.PDF_EXPORT_TASK:
|
||||
await this.processExportTask(job.data.fileTaskId);
|
||||
break;
|
||||
@@ -49,6 +52,19 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private getConfluenceApiImportService() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require('./../../../ee/confluence-api-import/confluence-api-import.service');
|
||||
return this.moduleRef.get(mod.ConfluenceApiImportService, {
|
||||
strict: false,
|
||||
});
|
||||
}
|
||||
|
||||
private async processConfluenceApiImport(fileTaskId: string): Promise<void> {
|
||||
const service = this.getConfluenceApiImportService();
|
||||
await service.processImport(fileTaskId);
|
||||
}
|
||||
|
||||
private async processExportTask(fileTaskId: string): Promise<void> {
|
||||
const pdfExportService = this.getPdfExportService();
|
||||
await pdfExportService.generateAndStorePdf(fileTaskId);
|
||||
@@ -77,6 +93,8 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
await this.handleFailedImportJob(job);
|
||||
} else if (job.name === QueueJob.PDF_EXPORT_TASK) {
|
||||
await this.handleFailedExportJob(job);
|
||||
} else if (job.name === QueueJob.CONFLUENCE_API_IMPORT) {
|
||||
await this.handleFailedExportJob(job);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -452,16 +452,7 @@ export class ImportAttachmentService {
|
||||
|
||||
const audioExtensions = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.webm', '.flac', '.aac']);
|
||||
|
||||
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') {
|
||||
if (ext === '.mp4') {
|
||||
const $video = $('<video>')
|
||||
.attr('src', apiFilePath)
|
||||
.attr('data-attachment-id', attachmentId)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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>');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
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,6 +11,7 @@ export enum FileImportSource {
|
||||
Generic = 'generic',
|
||||
Notion = 'notion',
|
||||
Confluence = 'confluence',
|
||||
ConfluenceApi = 'confluence-api'
|
||||
}
|
||||
|
||||
export enum FileTaskStatus {
|
||||
|
||||
@@ -97,14 +97,21 @@ export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||
}
|
||||
}
|
||||
|
||||
import { applyConfluenceMarginLeftIndent } from './confluence-indent';
|
||||
export { applyConfluenceMarginLeftIndent };
|
||||
|
||||
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||
normalizeTableColumnWidths($, $root);
|
||||
applyConfluenceMarginLeftIndent($, $root);
|
||||
|
||||
// Auto-embed only when the <a> is the sole meaningful child of its parent
|
||||
// block. A link mixed with surrounding text 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') return;
|
||||
if (!isSoleMeaningfulChild($el, 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);
|
||||
@@ -120,6 +127,21 @@ 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,6 +4,7 @@ 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}',
|
||||
@@ -31,6 +32,7 @@ export enum QueueJob {
|
||||
FIRST_PAYMENT_EMAIL = 'first-payment-email',
|
||||
|
||||
IMPORT_TASK = 'import-task',
|
||||
CONFLUENCE_API_IMPORT = 'confluence-api-import-task',
|
||||
EXPORT_TASK = 'export-task',
|
||||
|
||||
SEARCH_INDEX_PAGE = 'search-index-page',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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,105 +1,18 @@
|
||||
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: (environmentService: EnvironmentService) => {
|
||||
const redisConfig = parseRedisUrl(environmentService.getRedisUrl());
|
||||
return {
|
||||
connection: {
|
||||
host: redisConfig.host,
|
||||
port: redisConfig.port,
|
||||
password: redisConfig.password,
|
||||
db: redisConfig.db,
|
||||
family: redisConfig.family,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
},
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 20 * 1000,
|
||||
},
|
||||
removeOnComplete: {
|
||||
count: 200,
|
||||
},
|
||||
removeOnFail: {
|
||||
count: 100,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
useFactory: bullConfigFactory,
|
||||
inject: [EnvironmentService],
|
||||
}),
|
||||
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.BASE_QUEUE,
|
||||
defaultJobOptions: {
|
||||
attempts: 2,
|
||||
removeOnComplete: { count: 200 },
|
||||
removeOnFail: { count: 100 },
|
||||
},
|
||||
}),
|
||||
QueueProducersModule,
|
||||
],
|
||||
exports: [BullModule],
|
||||
providers: [GeneralQueueProcessor],
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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,
|
||||
password: redisConfig.password,
|
||||
db: redisConfig.db,
|
||||
family: redisConfig.family,
|
||||
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.BASE_QUEUE,
|
||||
defaultJobOptions: {
|
||||
attempts: 2,
|
||||
removeOnComplete: { count: 200 },
|
||||
removeOnFail: { count: 100 },
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -49,6 +49,8 @@ export class StaticModule implements OnModuleInit {
|
||||
: undefined,
|
||||
POSTHOG_HOST: this.environmentService.getPostHogHost(),
|
||||
POSTHOG_KEY: this.environmentService.getPostHogKey(),
|
||||
BETA_CONFLUENCE_IMPORTER:
|
||||
this.environmentService.isBetaConfluenceImporter(),
|
||||
};
|
||||
|
||||
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
|
||||
|
||||
+8
-8
@@ -23,11 +23,11 @@
|
||||
"@casl/ability": "6.8.0",
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@floating-ui/dom": "1.7.3",
|
||||
"@hocuspocus/common": "4.4.0",
|
||||
"@hocuspocus/provider": "4.4.0",
|
||||
"@hocuspocus/provider-react": "4.4.0",
|
||||
"@hocuspocus/server": "4.4.0",
|
||||
"@hocuspocus/transformer": "4.4.0",
|
||||
"@hocuspocus/common": "4.5.0",
|
||||
"@hocuspocus/provider": "4.5.0",
|
||||
"@hocuspocus/provider-react": "4.5.0",
|
||||
"@hocuspocus/server": "4.5.0",
|
||||
"@hocuspocus/transformer": "4.5.0",
|
||||
"@joplin/turndown": "4.0.82",
|
||||
"@joplin/turndown-plugin-gfm": "1.0.64",
|
||||
"@sindresorhus/slugify": "3.0.0",
|
||||
@@ -65,7 +65,7 @@
|
||||
"date-fns": "4.1.0",
|
||||
"diff": "8.0.3",
|
||||
"docx": "9.7.1",
|
||||
"dompurify": "3.4.12",
|
||||
"dompurify": "3.4.13",
|
||||
"fractional-indexing-jittered": "1.0.0",
|
||||
"highlight.js": "11.11.1",
|
||||
"image-dimensions": "2.5.0",
|
||||
@@ -81,12 +81,12 @@
|
||||
"yjs": "^13.6.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nx/js": "22.7.2",
|
||||
"@nx/js": "23.1.1",
|
||||
"@types/bytes": "3.1.5",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/turndown": "5.0.6",
|
||||
"concurrently": "10.0.4",
|
||||
"nx": "22.7.2",
|
||||
"nx": "23.1.1",
|
||||
"tsx": "^4.21.0"
|
||||
},
|
||||
"workspaces": {
|
||||
|
||||
@@ -23,6 +23,7 @@ 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";
|
||||
@@ -32,6 +33,7 @@ export * from "./lib/columns";
|
||||
export * from "./lib/status";
|
||||
export * from "./lib/pdf";
|
||||
export * from "./lib/page-break";
|
||||
export * from "./lib/footnotes";
|
||||
export * from "./lib/resizable-nodeview";
|
||||
export {
|
||||
pageNodeToDocxBuffer,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//Source MIT - https://github.com/buttondown/tiptap-footnotes
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import ListItem, { ListItemOptions } from "@tiptap/extension-list-item";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
footnote: {
|
||||
/**
|
||||
* scrolls to & sets the text selection at the end of the footnote with the given id
|
||||
* @param id the id of the footote (i.e. the `data-id` attribute value of the footnote)
|
||||
* @example editor.commands.focusFootnote("a43956c1-1ab8-462f-96e4-be3a4b27fd50")
|
||||
*/
|
||||
focusFootnote: (id: string) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface FootnoteOptions extends ListItemOptions {
|
||||
/**
|
||||
* Content expression for this node
|
||||
* @default "paragraph+"
|
||||
*/
|
||||
content: string;
|
||||
}
|
||||
|
||||
const Footnote = ListItem.extend<FootnoteOptions>({
|
||||
name: "footnote",
|
||||
content() {
|
||||
return this.options.content;
|
||||
},
|
||||
isolating: true,
|
||||
defining: true,
|
||||
draggable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
bulletListTypeName: 'bulletList',
|
||||
orderedListTypeName: 'orderedList',
|
||||
...this.parent?.(),
|
||||
content: "paragraph+",
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
isRequired: true,
|
||||
},
|
||||
// the data-id field should match the data-id field of a footnote reference.
|
||||
// it's used to link footnotes and references together.
|
||||
"data-id": {
|
||||
isRequired: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "li",
|
||||
getAttrs(node) {
|
||||
const id = node.getAttribute("data-id");
|
||||
if (id) {
|
||||
return {
|
||||
"data-id": node.getAttribute("data-id"),
|
||||
};
|
||||
}
|
||||
return false;
|
||||
},
|
||||
priority: 1000,
|
||||
},
|
||||
];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"li",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
focusFootnote:
|
||||
(id: string) =>
|
||||
({ editor, chain }) => {
|
||||
const matchedFootnote = editor.$node("footnote", {
|
||||
"data-id": id,
|
||||
});
|
||||
if (matchedFootnote) {
|
||||
// sets the text selection to the end of the footnote definition and scroll to it.
|
||||
chain()
|
||||
.focus()
|
||||
.setTextSelection(
|
||||
matchedFootnote.from + matchedFootnote.content.size
|
||||
)
|
||||
.run();
|
||||
|
||||
matchedFootnote.element.scrollIntoView();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// when inside a footnote, Mod-a should select only the footnote content
|
||||
"Mod-a": ({ editor }) => {
|
||||
try {
|
||||
const { selection } = editor.state;
|
||||
const { $from } = selection;
|
||||
|
||||
for (let depth = $from.depth; depth >= 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type.name === "footnote") {
|
||||
const start = $from.start(depth);
|
||||
const end = $from.end(depth);
|
||||
|
||||
editor.commands.setTextSelection({
|
||||
from: start + 1,
|
||||
to: end - 1,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
// when the user presses tab, adjust the text selection to be at the end of the next footnote
|
||||
Tab: ({ editor }) => {
|
||||
try {
|
||||
const { selection } = editor.state;
|
||||
const pos = editor.$pos(selection.anchor);
|
||||
if (!pos.after) return false;
|
||||
// if the next node is "footnotes", place the text selection at the end of the first footnote
|
||||
if (pos.after.node.type.name == "footnotes") {
|
||||
const firstChild = pos.after.node.child(0);
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(pos.after.from + firstChild.content.size)
|
||||
.scrollIntoView()
|
||||
.run();
|
||||
return true;
|
||||
} else {
|
||||
const startPos = selection.$from.start(2);
|
||||
if (Number.isNaN(startPos)) return false;
|
||||
const parent = editor.$pos(startPos);
|
||||
if (parent.node.type.name != "footnote" || !parent.after) {
|
||||
return false;
|
||||
}
|
||||
// if the next node is a footnote, place the text selection at the end of it
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(parent.after.to - 1)
|
||||
.scrollIntoView()
|
||||
.run();
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
// inverse of the tab command - place the text selection at the end of the previous footnote
|
||||
"Shift-Tab": ({ editor }) => {
|
||||
const { selection } = editor.state;
|
||||
const startPos = selection.$from.start(2);
|
||||
if (Number.isNaN(startPos)) return false;
|
||||
const parent = editor.$pos(startPos);
|
||||
if (parent.node.type.name != "footnote" || !parent.before) {
|
||||
return false;
|
||||
}
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(parent.before.to - 1)
|
||||
.scrollIntoView()
|
||||
.run();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default Footnote;
|
||||
@@ -0,0 +1,46 @@
|
||||
//Source MIT - https://github.com/buttondown/tiptap-footnotes
|
||||
import OrderedList from "@tiptap/extension-ordered-list";
|
||||
import FootnoteRules from "./rules";
|
||||
|
||||
const Footnotes = OrderedList.extend({
|
||||
name: "footnotes",
|
||||
group: "", // removed the default group of the ordered list extension
|
||||
isolating: true,
|
||||
defining: true,
|
||||
draggable: false,
|
||||
|
||||
content() {
|
||||
return "footnote*";
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
class: {
|
||||
default: "footnotes",
|
||||
},
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "ol.footnotes",
|
||||
priority: 1000,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {};
|
||||
},
|
||||
addCommands() {
|
||||
return {};
|
||||
},
|
||||
addInputRules() {
|
||||
return [];
|
||||
},
|
||||
|
||||
addExtensions() {
|
||||
return [FootnoteRules];
|
||||
},
|
||||
});
|
||||
|
||||
export default Footnotes;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as Footnotes } from "./footnotes";
|
||||
export { default as Footnote } from "./footnote";
|
||||
export type { FootnoteOptions } from "./footnote";
|
||||
export { default as FootnoteReference } from "./reference";
|
||||
@@ -0,0 +1,221 @@
|
||||
//Source MIT - https://github.com/buttondown/tiptap-footnotes
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import {
|
||||
Fragment as PMFragment,
|
||||
Node as PMNode,
|
||||
Slice,
|
||||
} from "@tiptap/pm/model";
|
||||
import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { generateNodeId } from "../utils";
|
||||
|
||||
|
||||
const REFNUM_ATTR = "data-reference-number";
|
||||
const REF_CLASS = "footnote-ref";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
footnoteReference: {
|
||||
/**
|
||||
* add a new footnote reference
|
||||
* @example editor.commands.addFootnote()
|
||||
*/
|
||||
addFootnote: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const FootnoteReference = Node.create({
|
||||
name: "footnoteReference",
|
||||
inline: true,
|
||||
content: "text*",
|
||||
group: "inline",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `sup`,
|
||||
priority: 1000,
|
||||
getAttrs(node) {
|
||||
const anchor = node.querySelector<HTMLAnchorElement>(
|
||||
`a.${REF_CLASS}:first-child`
|
||||
);
|
||||
|
||||
if (!anchor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const id = anchor.getAttribute("data-id");
|
||||
const ref = anchor.getAttribute(REFNUM_ATTR);
|
||||
|
||||
return {
|
||||
"data-id": id ?? generateNodeId(),
|
||||
referenceNumber: ref ?? anchor.innerText,
|
||||
};
|
||||
},
|
||||
contentElement(node) {
|
||||
return node.firstChild as HTMLElement;
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
class: {
|
||||
default: REF_CLASS,
|
||||
},
|
||||
"data-id": {
|
||||
renderHTML(attributes) {
|
||||
return {
|
||||
"data-id": attributes["data-id"] || generateNodeId(),
|
||||
};
|
||||
},
|
||||
},
|
||||
referenceNumber: {},
|
||||
|
||||
href: {
|
||||
renderHTML(attributes) {
|
||||
return {
|
||||
href: `#fn:${attributes["referenceNumber"]}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const { referenceNumber, ...attributes } = HTMLAttributes;
|
||||
const attrs = mergeAttributes(this.options.HTMLAttributes, attributes);
|
||||
attrs[REFNUM_ATTR] = referenceNumber;
|
||||
|
||||
return [
|
||||
"sup",
|
||||
{ id: `fnref:${referenceNumber}` },
|
||||
["a", attrs, HTMLAttributes.referenceNumber],
|
||||
];
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
|
||||
// Ensures pasted footnote references get unique IDs.
|
||||
const mapNode = (node: PMNode): PMNode => {
|
||||
if (node.type.name === this.name) {
|
||||
const newAttrs = { ...node.attrs, "data-id": generateNodeId() };
|
||||
return node.type.create(newAttrs, node.content, node.marks);
|
||||
}
|
||||
|
||||
if (node.content && node.content.size > 0) {
|
||||
const newChildren: PMNode[] = [];
|
||||
let changed = false;
|
||||
|
||||
node.content.forEach((child) => {
|
||||
const mapped = mapNode(child);
|
||||
if (mapped !== child) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
newChildren.push(mapped);
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
return node.copy(PMFragment.from(newChildren));
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("footnotePasteHandler"),
|
||||
props: {
|
||||
transformPasted(slice) {
|
||||
const mappedNodes: PMNode[] = [];
|
||||
let changed = false;
|
||||
|
||||
slice.content.forEach((node) => {
|
||||
const mapped = mapNode(node);
|
||||
if (mapped !== node) {
|
||||
changed = true;
|
||||
}
|
||||
mappedNodes.push(mapped);
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return slice;
|
||||
}
|
||||
|
||||
return new Slice(
|
||||
PMFragment.from(mappedNodes),
|
||||
slice.openStart,
|
||||
slice.openEnd
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
new Plugin({
|
||||
key: new PluginKey("footnoteRefClick"),
|
||||
|
||||
props: {
|
||||
// on double-click, focus on the footnote
|
||||
handleDoubleClickOn(view, pos, node, nodePos, event) {
|
||||
if (node.type.name != "footnoteReference") return false;
|
||||
event.preventDefault();
|
||||
const id = node.attrs["data-id"];
|
||||
return editor.commands.focusFootnote(id);
|
||||
},
|
||||
// click the footnote reference once to get focus, click twice to scroll to the footnote
|
||||
handleClickOn(view, pos, node, nodePos, event) {
|
||||
if (node.type.name != "footnoteReference") return false;
|
||||
event.preventDefault();
|
||||
const { selection } = editor.state.tr;
|
||||
if (selection instanceof NodeSelection && selection.node.eq(node)) {
|
||||
const id = node.attrs["data-id"];
|
||||
return editor.commands.focusFootnote(id);
|
||||
} else {
|
||||
editor.chain().setNodeSelection(nodePos).run();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
addFootnote:
|
||||
() =>
|
||||
({ state, tr }) => {
|
||||
const node = this.type.create({
|
||||
"data-id": generateNodeId(),
|
||||
});
|
||||
tr.insert(state.selection.anchor, node);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
// when a user types [^text], add a new footnote
|
||||
return [
|
||||
{
|
||||
find: /\[\^(.*?)\]/,
|
||||
type: this.type,
|
||||
undoable: true,
|
||||
handler({ range, match, chain }) {
|
||||
const start = range.from;
|
||||
let end = range.to;
|
||||
if (match[1]) {
|
||||
chain().deleteRange({ from: start, to: end }).addFootnote().run();
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export default FootnoteReference;
|
||||
@@ -0,0 +1,90 @@
|
||||
//Source MIT - https://github.com/buttondown/tiptap-footnotes
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { ReplaceStep } from "@tiptap/pm/transform";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { updateFootnotesList } from "./utils";
|
||||
|
||||
const FootnoteRules = Extension.create({
|
||||
name: "footnoteRules",
|
||||
priority: 1000,
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("footnoteRules"),
|
||||
filterTransaction(tr) {
|
||||
const { from, to } = tr.selection;
|
||||
|
||||
// Allow full document selections (Mod-a/Ctrl-a)
|
||||
if (from === 0 && to === tr.doc.content.size) return true;
|
||||
|
||||
let selectedFootnotes = false;
|
||||
let selectedContent = false;
|
||||
let footnoteCount = 0;
|
||||
tr.doc.nodesBetween(from, to, (node, _, parent) => {
|
||||
if (parent?.type.name == "doc" && node.type.name != "footnotes") {
|
||||
selectedContent = true;
|
||||
} else if (node.type.name == "footnote") {
|
||||
footnoteCount += 1;
|
||||
} else if (node.type.name == "footnotes") {
|
||||
selectedFootnotes = true;
|
||||
}
|
||||
});
|
||||
const overSelected = selectedContent && selectedFootnotes;
|
||||
/*
|
||||
* Here, we don't allow any transaction that spans between the "content" nodes and the "footnotes" node. This also rejects any transaction that spans between more than 1 footnote.
|
||||
*/
|
||||
return !overSelected && footnoteCount <= 1;
|
||||
},
|
||||
|
||||
// if there are some to the footnote references (added/deleted/dragged), append a transaction that updates the footnotes list accordingly
|
||||
appendTransaction(transactions, oldState, newState) {
|
||||
let newTr = newState.tr;
|
||||
let refsChanged = false; // true if the footnote references have been changed, false otherwise
|
||||
for (let tr of transactions) {
|
||||
if (!tr.docChanged) continue;
|
||||
if (refsChanged) break;
|
||||
|
||||
for (let step of tr.steps) {
|
||||
if (!(step instanceof ReplaceStep)) continue;
|
||||
if (refsChanged) break;
|
||||
|
||||
const isDelete = step.from != step.to; // the user deleted items from the document (from != to & the step is a replace step)
|
||||
const isInsert = step.slice.size > 0;
|
||||
|
||||
// check if any footnote references have been inserted
|
||||
if (isInsert) {
|
||||
step.slice.content.descendants((node) => {
|
||||
if (node?.type.name == "footnoteReference") {
|
||||
refsChanged = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (isDelete && !refsChanged) {
|
||||
// check if any footnote references have been deleted
|
||||
tr.before.nodesBetween(
|
||||
step.from,
|
||||
Math.min(tr.before.content.size, step.to), // make sure to not go over the old document's limit
|
||||
(node) => {
|
||||
if (node.type.name == "footnoteReference") {
|
||||
refsChanged = true;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (refsChanged) {
|
||||
updateFootnotesList(newTr, newState);
|
||||
return newTr;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
export default FootnoteRules;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user