Compare commits

...
Author SHA1 Message Date
Philipinho 50e81ade57 sync 2026-08-11 14:59:58 +01:00
Philipinho 228fe14cd8 fix: arbitrary colors in dark mode 2026-08-11 14:32:28 +01:00
Philipinho 2fe13daa09 Merge branch 'main' into confluence-importer 2026-08-10 18:10:54 +01:00
Philip Okugbe 7439da2f6e chore: package updates (#2383)
* update

* update versions

* package updates
2026-08-10 17:56:12 +01:00
Philip Okugbe 305fd40686 fix: browser tab showing url instead of page title (#2382) 2026-08-10 17:38:03 +01:00
Philipinho 4bd51d7404 fix: self-reference parent page 2026-08-10 17:37:12 +01:00
Philipinho 44086cbb01 sync 2026-08-10 16:54:11 +01:00
Philip Okugbe 89378ee766 chore: package updates (#2378)
* chore: package updates

* remove stale overrides
2026-08-06 23:41:36 +01:00
Philipinho a81e5c42ec sync 2026-08-03 23:05:28 +01:00
Philipinho 65af1cc2a4 sync 2026-08-03 16:25:50 +01:00
Philipinho 231f0deeb0 fix highlight color 2026-08-03 02:13:45 +01:00
Philipinho b9d8612ecd Merge branch 'main' into confluence-importer 2026-08-02 22:30:06 +01:00
Philip Okugbe 56ac42767a fix: stale placeholder decorations on empty paragraphs (#2374) 2026-08-02 21:07:50 +01:00
Philipinho 38380211a5 fix: dedupe duplicate packages 2026-08-01 11:57:05 +01:00
Philip Okugbe cd34594b5d fix: placeholder bug (#2373)
* fix placeholder check

* tiptap v3.29.2

* add s3 max socket env
2026-08-01 11:45:25 +01:00
Philip Okugbe 70d2ff8685 fix: increase s3 client max socket limit (#2355) 2026-07-21 22:59:25 +01:00
Philipinho 0ba2d78660 chore: update pnpm and axios 2026-07-20 20:46:02 +01:00
Philip Okugbe 3ed505d2be fix: toolbar flicker on navigation (#2352) 2026-07-20 20:36:03 +01:00
Philip Okugbe a55057db37 feat: collab hocuspocus v4 upgrade (#2351)
* WIP 1

* complete v4 migration

* add flushDelay

* feat: multiplexing

* fix: survive hocuspocus v4 message timeout

* fix: searchTerm type error
2026-07-20 20:34:09 +01:00
Philip Okugbe ce8fbb86ff chore: upgrade to node 26 and pnpm 11 (#2342)
* chore: pnpm 11

* chore: set minimum age and allowedBuilds

* Node 26; pnpm 11

* prod package overrides

* dev package overides

* cleanup
2026-07-20 19:44:51 +01:00
Philipinho e76c138e4c sync 2026-07-09 21:39:17 +01:00
Philipinho e146cfd075 fix: supported comment nodes 2026-07-09 21:33:40 +01:00
Philipinho efcca2e4fd sync 2026-07-03 21:20:31 +01:00
Philipinho 3b57ce7c89 fix: comment error boundary 2026-07-03 21:12:09 +01:00
Philipinho 86687d2bf2 add image and table node to comment editor
* fix react error
2026-07-03 20:10:07 +01:00
Philipinho 15f893184e Merge branch 'main' into confluence-importer 2026-07-03 16:21:19 +01:00
Philipinho dc3f7b0be4 Merge branch 'main' into confluence-importer 2026-07-01 23:05:52 +01:00
Philipinho 88e057c76e fix: don't auto embed pdf 2026-06-30 02:48:10 +01:00
Philipinho 89bf9f3078 Merge branch 'main' into confluence-importer 2026-06-22 00:49:19 +01:00
Philipinho b0f7c11fb3 sync 2026-06-16 00:31:02 +01:00
Philipinho feb5167771 feat(confluence-import): hide API importer behind BETA_CONFLUENCE_IMPORTER flag 2026-06-15 21:43:38 +01:00
Philipinho 97a1248818 feat(confluence-import): gate API importer UI to enterprise entitlement 2026-06-12 13:16:47 +01:00
Philipinho b7d7ad4521 feat(licence): add confluence API import feature key 2026-06-12 13:12:42 +01:00
Philipinho 79df1229cb feat: import page permissions 2026-06-12 12:59:31 +01:00
Philipinho 812d0765b5 types 2026-05-18 16:58:52 +01:00
Philipinho 234bd13453 fix(auth): clear page indexeddb databases on logout 2026-05-18 01:59:43 +01:00
Philipinho affd2e6740 feat(comment): enable text color in comments 2026-05-18 00:14:59 +01:00
Philipinho 37e652ade5 chore: bump ee submodule 2026-05-18 00:14:56 +01:00
Philipinho 38a847f305 fix(confluence-api-import): rename list endpoint to history 2026-05-18 00:14:53 +01:00
Philipinho 7c80688698 feat(confluence-api-import): show user count in import history 2026-05-18 00:14:46 +01:00
Philipinho 0320e07bb1 feat(confluence-api-import): add import mappings type 2026-05-18 00:14:44 +01:00
Philipinho 1982a0ed1e feat: encryption module 2026-05-16 23:40:43 +01:00
Philipinho fcb2b08cf3 docs(import): trim auto-embed comment 2026-05-16 21:53:25 +01:00
Philipinho e5ff58a73a fix(import): only auto-embed links when they're the sole content of their block 2026-05-16 21:00:41 +01:00
Philipinho d3bb105366 feat(import): add deterministic anchor nodeId helper 2026-05-16 18:24:44 +01:00
Philipinho bbb1b5eb26 feat(import): add Confluence emoji-id parser with tests 2026-05-16 01:28:10 +01:00
Philipinho 3e66aff1e2 feat(import): extract Confluence indent helper with auto-detect unit and tests 2026-05-16 01:21:14 +01:00
Philipinho 89442f5154 feat(import): add shared Confluence margin-left indent normalizer 2026-05-16 01:10:28 +01:00
Philipinho 12ef426541 Merge branch 'main' into confluence 2026-05-15 13:26:33 +01:00
Philipinho 93ff3f43b7 fix badge 2026-04-22 13:54:05 +01:00
Philipinho cbf03fc956 fix 2026-04-22 12:58:19 +01:00
Philipinho 68309247b5 share html normalization between zip and single-file imports 2026-04-22 01:36:14 +01:00
Philipinho 474ff6c629 normalize column widths during zip import html formatting 2026-04-22 00:57:00 +01:00
Philipinho c5e9be6b36 add shared table column width normalizer for imports 2026-04-22 00:51:42 +01:00
Philipinho 00e8c53852 types 2026-04-21 15:44:14 +01:00
103 changed files with 5325 additions and 3552 deletions
-1
View File
@@ -1 +0,0 @@
shamefully-hoist = true
+2 -3
View File
@@ -1,7 +1,7 @@
FROM node:22-slim AS base
FROM node:26-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@10.4.0
RUN npm install -g pnpm@11.15.1
FROM base AS builder
@@ -34,7 +34,6 @@ COPY --from=builder /app/packages/base-formula/package.json /app/packages/base-f
# Copy root package files
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/
COPY --from=builder /app/.npmrc /app/.npmrc
# Copy patches
COPY --from=builder /app/patches /app/patches
+5 -5
View File
@@ -36,7 +36,7 @@
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.14.3",
"alfaaz": "1.1.0",
"axios": "1.16.0",
"axios": "1.18.1",
"blueimp-load-image": "5.16.0",
"clsx": "2.1.1",
"file-saver": "2.0.5",
@@ -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",
@@ -61,7 +61,7 @@
"react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0",
"react-i18next": "16.5.8",
"react-router-dom": "7.18.0",
"react-router-dom": "7.18.2",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"zod": "4.3.6"
@@ -86,7 +86,7 @@
"globals": "15.13.0",
"jsdom": "25.0.0",
"optics-ts": "2.4.1",
"postcss": "8.5.14",
"postcss": "8.5.25",
"postcss-preset-mantine": "1.18.0",
"postcss-simple-vars": "7.0.1",
"prettier": "3.8.1",
@@ -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",
+5
View File
@@ -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;
@@ -0,0 +1,45 @@
import { describe, expect, it, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { HelmetProvider } from "react-helmet-async";
import { DocumentTitle } from "./document-title.tsx";
const renderTitle = (ui: React.ReactNode) =>
render(<HelmetProvider>{ui}</HelmetProvider>);
describe("DocumentTitle", () => {
beforeEach(() => {
document.head.innerHTML = "<title>Docmost</title>";
});
it("appends the app name", () => {
renderTitle(<DocumentTitle title="Home" />);
expect(document.title).toBe("Home - Docmost");
});
it("omits the app name when asked", () => {
renderTitle(<DocumentTitle title="My page" withAppName={false} />);
expect(document.title).toBe("My page");
});
it("falls back to the app name without a title", () => {
renderTitle(<DocumentTitle />);
expect(document.title).toBe("Docmost");
});
it("never renders an empty title", () => {
renderTitle(<DocumentTitle title="Spaces" />);
const titles = Array.from(document.querySelectorAll("head > title"));
expect(titles.every((node) => node.textContent !== "")).toBe(true);
});
it("renders extra head children", () => {
renderTitle(
<DocumentTitle title="Shared">
<meta name="robots" content="noindex" />
</DocumentTitle>,
);
expect(
document.querySelector('head > meta[name="robots"]')?.getAttribute("content"),
).toBe("noindex");
});
});
@@ -0,0 +1,29 @@
import React from "react";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
type DocumentTitleProps = {
title?: string;
withAppName?: boolean;
children?: React.ReactNode;
};
export function DocumentTitle({
title,
withAppName = true,
children,
}: DocumentTitleProps) {
const appName = getAppName();
let documentTitle = appName;
if (title) {
documentTitle = withAppName ? `${title} - ${appName}` : title;
}
return (
<Helmet>
<title>{documentTitle}</title>
{children}
</Helmet>
);
}
+2 -4
View File
@@ -1,17 +1,15 @@
import { Title, Text, Button, Container, Group } from "@mantine/core";
import classes from "./error-404.module.css";
import { Link } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export function Error404() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("404 page not found")} - Docmost</title>
</Helmet>
<DocumentTitle title={t("404 page not found")} />
<Container className={classes.root}>
<Title className={classes.title}>{t("404 page not found")}</Title>
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
+2 -5
View File
@@ -1,5 +1,3 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
@@ -15,6 +13,7 @@ import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { isCloud } from "@/lib/config.ts";
import { useLocation, useNavigate } from "react-router-dom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function AiSettings() {
const { t } = useTranslation();
@@ -40,9 +39,7 @@ export default function AiSettings() {
return (
<>
<Helmet>
<title>AI settings - {getAppName()}</title>
</Helmet>
<DocumentTitle title="AI settings" />
<SettingsTitle title={t("AI settings")} />
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
@@ -1,10 +1,9 @@
import React, { useState } from "react";
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName, getAppUrl } from "@/lib/config";
import { getAppUrl } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
@@ -17,6 +16,7 @@ import { IApiKey } from "@/ee/api-key";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function UserApiKeys() {
const { t } = useTranslation();
@@ -49,11 +49,7 @@ export default function UserApiKeys() {
return (
<>
<Helmet>
<title>
{t("API keys")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("API keys")} />
<SettingsTitle title={t("API keys")} />
@@ -1,9 +1,7 @@
import React, { useState } from "react";
import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
@@ -15,6 +13,7 @@ import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx';
import RestrictApiToAdmins from "@/ee/api-key/components/restrict-api-to-admins";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function WorkspaceApiKeys() {
const { t } = useTranslation();
@@ -47,11 +46,7 @@ export default function WorkspaceApiKeys() {
return (
<>
<Helmet>
<title>
{t("API management")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("API management")} />
<SettingsTitle title={t("API management")} />
@@ -10,11 +10,9 @@ import {
Text,
Tooltip,
} from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { IconSettings } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import {
@@ -26,6 +24,7 @@ import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
import useUserRole from "@/hooks/use-user-role";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
type RetentionUnit = "days" | "months" | "years";
@@ -97,11 +96,7 @@ export default function AuditLogs() {
return (
<>
<Helmet>
<title>
{t("Audit log")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Audit log")} />
<SettingsTitle title={t("Audit log")} />
+2 -5
View File
@@ -1,5 +1,3 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import BillingPlans from "@/ee/billing/components/billing-plans.tsx";
import BillingTrial from "@/ee/billing/components/billing-trial.tsx";
@@ -9,6 +7,7 @@ import React from "react";
import BillingDetails from "@/ee/billing/components/billing-details.tsx";
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Billing() {
const { data: billing, isError: isBillingError } = useBillingQuery();
@@ -20,9 +19,7 @@ export default function Billing() {
return (
<>
<Helmet>
<title>Billing - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Billing" />
<SettingsTitle title="Billing" />
<BillingTrial />
@@ -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[];
};
+1
View File
@@ -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',
+2 -5
View File
@@ -1,5 +1,3 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
@@ -9,6 +7,7 @@ import InstallationDetails from "@/ee/licence/components/installation-details.ts
import OssDetails from "@/ee/licence/components/oss-details.tsx";
import { useAtom } from "jotai/index";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function License() {
const [entitlements] = useAtom(entitlementAtom);
@@ -21,9 +20,7 @@ export default function License() {
return (
<>
<Helmet>
<title>License - {getAppName()}</title>
</Helmet>
<DocumentTitle title="License" />
<SettingsTitle title="License" />
<ActivateLicenseForm />
@@ -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>
@@ -1,17 +1,16 @@
import { useState, useMemo } from "react";
import { Group, MultiSelect, Select, Space, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { IconSearch } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useVerificationListQuery } from "@/ee/page-verification/queries/page-verification-query";
import { IVerificationListParams } from "@/ee/page-verification/types/page-verification.types";
import VerificationListTable from "@/ee/page-verification/components/verification-list-table";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function VerifiedPages() {
const { t } = useTranslation();
@@ -68,11 +67,7 @@ export default function VerifiedPages() {
return (
<>
<Helmet>
<title>
{t("Verified pages")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Verified pages")} />
<SettingsTitle title={t("Verified pages")} />
+2 -7
View File
@@ -1,18 +1,13 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { CloudLoginForm } from "@/ee/components/cloud-login-form.tsx";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function CloudLogin() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Login")} />
<CloudLoginForm />
</>
@@ -1,14 +1,11 @@
import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx";
import { Helmet } from "react-helmet-async";
import React from "react";
import { getAppName } from "@/lib/config.ts";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function CreateWorkspace() {
return (
<>
<Helmet>
<title>Create Workspace - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Create Workspace" />
<SetupWorkspaceForm />
</>
);
@@ -1,5 +1,4 @@
import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import { isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import {
Alert,
@@ -37,6 +36,7 @@ import EnableScim from "@/ee/scim/components/enable-scim";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import Paginate from "@/components/common/paginate";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
const SCIM_TOKEN_LIMIT = 5;
@@ -64,9 +64,7 @@ export default function Security() {
return (
<>
<Helmet>
<title>Security - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Security" />
<SettingsTitle title={t("Security")} />
<EnforceMfa />
@@ -22,8 +22,6 @@ import { useTranslation } from "react-i18next";
import { useDisclosure, useWindowEvent } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { Link, useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config";
import { useEditor, EditorContent } from "@tiptap/react";
import { templateExtensions } from "@/features/editor/extensions/extensions";
import {
@@ -44,6 +42,7 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import classes from "./template-editor.module.css";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function TemplateEditor() {
const { t } = useTranslation();
@@ -247,11 +246,7 @@ export default function TemplateEditor() {
return (
<>
<Helmet>
<title>
{t("Edit template")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Edit template")} />
{editorToolbarEnabled && editor && (
<FixedToolbar editor={editor} templateMode />
@@ -13,11 +13,9 @@ import {
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { IconPlus } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useDisclosure } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import {
useGetTemplatesQuery,
useDeleteTemplateMutation,
@@ -31,6 +29,7 @@ import useUserRole from "@/hooks/use-user-role";
import CreateTemplateModal from "@/ee/template/components/create-template-modal";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function TemplateList() {
const { t } = useTranslation();
@@ -102,11 +101,7 @@ export default function TemplateList() {
return (
<>
<Helmet>
<title>
{t("Templates")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Templates")} />
<Container size="900" pt="xl">
<Group justify="space-between" mb="xl">
@@ -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,6 +1,8 @@
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
import { Placeholder } from "@tiptap/extension-placeholder";
import { StarterKit } from "@tiptap/starter-kit";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import { Mention, LinkExtension } from "@docmost/editor-ext";
import classes from "./comment.module.css";
import { useFocusWithin } from "@mantine/hooks";
@@ -11,6 +13,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 +53,8 @@ const CommentEditor = forwardRef(
placeholder: placeholder || t("Reply..."),
}),
LinkExtension,
TextStyle,
Color,
EmojiCommand,
Mention.configure({
suggestion: {
@@ -66,6 +72,11 @@ const CommentEditor = forwardRef(
return ReactNodeViewRenderer(MentionView);
},
}),
TableKit,
TaskList,
TaskItem.configure({
nested: true,
}),
],
editorProps: {
attributes: {
@@ -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 {
}
@@ -0,0 +1,46 @@
import {
HocuspocusProviderWebsocket,
WebSocketStatus,
} from "@hocuspocus/provider";
import { getCollaborationUrl } from "@/lib/config.ts";
const RELEASE_GRACE_MS = 5000;
let socket: HocuspocusProviderWebsocket | null = null;
let editorCount = 0;
let releaseTimer: ReturnType<typeof setTimeout> | null = null;
export function getCollabSocket(): HocuspocusProviderWebsocket {
if (!socket) {
socket = new HocuspocusProviderWebsocket({
url: getCollaborationUrl(),
autoConnect: false,
});
}
return socket;
}
export function acquireCollabSocket(): void {
editorCount++;
if (releaseTimer) {
clearTimeout(releaseTimer);
releaseTimer = null;
}
const collabSocket = getCollabSocket();
collabSocket.shouldConnect = true;
if (collabSocket.status === WebSocketStatus.Disconnected) {
collabSocket.connect();
}
}
export function releaseCollabSocket(): void {
editorCount--;
if (editorCount > 0) return;
if (releaseTimer) clearTimeout(releaseTimer);
releaseTimer = setTimeout(() => {
releaseTimer = null;
if (editorCount === 0) {
socket?.disconnect();
}
}, RELEASE_GRACE_MS);
}
@@ -6,6 +6,7 @@
z-index: 99;
display: flex;
align-items: center;
min-height: 45px;
background: var(--mantine-color-body);
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
@@ -31,8 +31,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
if (!editor || !state) return null;
return (
<>
<div
@@ -49,22 +47,26 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
<div className={classes.divider} />
</>
)} */}
<BlockTypeGroup editor={editor} />
<div className={classes.divider} />
<InlineMarksGroup editor={editor} state={state} />
<div className={classes.divider} />
<ColorGroup editor={editor} />
<div className={classes.divider} />
<ListsGroup editor={editor} state={state} />
<div className={classes.divider} />
<AlignmentGroup editor={editor} />
<div className={classes.divider} />
<MediaGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} />
<QuickInsertsGroup editor={editor} />
<MoreInsertsGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} />
<HistoryGroup editor={editor} state={state} />
{editor && state && (
<>
<BlockTypeGroup editor={editor} />
<div className={classes.divider} />
<InlineMarksGroup editor={editor} state={state} />
<div className={classes.divider} />
<ColorGroup editor={editor} />
<div className={classes.divider} />
<ListsGroup editor={editor} state={state} />
<div className={classes.divider} />
<AlignmentGroup editor={editor} />
<div className={classes.divider} />
<MediaGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} />
<QuickInsertsGroup editor={editor} />
<MoreInsertsGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} />
<HistoryGroup editor={editor} state={state} />
</>
)}
</div>
</div>
<div className={classes.spacer} aria-hidden />
@@ -3,7 +3,8 @@ import { StarterKit } from "@tiptap/starter-kit";
import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list";
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
import { CharacterCount, UndoRedo } from "@tiptap/extensions";
import { Placeholder } from "@/features/editor/extensions/placeholder";
import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography";
@@ -194,16 +195,18 @@ export const mainExtensions = [
return i18n.t("Toggle title");
}
if (node.type.name === "paragraph") {
const $pos = editor.state.doc.resolve(pos);
const parentName = $pos.parent.type.name;
if (
parentName === "column" ||
parentName === "tableCell" ||
parentName === "tableHeader" ||
parentName === "callout" ||
parentName === "blockquote"
) {
return i18n.t("Write...");
const doc = editor.state.doc;
if (pos >= 0 && pos <= doc.content.size) {
const parentName = doc.resolve(pos).parent.type.name;
if (
parentName === "column" ||
parentName === "tableCell" ||
parentName === "tableHeader" ||
parentName === "callout" ||
parentName === "blockquote"
) {
return i18n.t("Write...");
}
}
return i18n.t('Write anything. Enter "/" for commands');
}
@@ -0,0 +1,64 @@
import { isNodeEmpty } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { Placeholder as TiptapPlaceholder } from "@tiptap/extensions";
export const Placeholder = TiptapPlaceholder.extend({
addProseMirrorPlugins() {
const editor = this.editor;
const options = this.options;
const dataAttribute = `data-${options.dataAttribute || "placeholder"}`;
return [
new Plugin({
key: new PluginKey("docmostPlaceholder"),
props: {
decorations: (state) => {
if (options.showOnlyWhenEditable && !editor.isEditable) {
return null;
}
const { doc, selection } = state;
const { anchor } = selection;
const decorations: Decoration[] = [];
const isEmptyDoc = editor.isEmpty;
doc.descendants((node, pos) => {
if (!node.type.isTextblock) {
return options.includeChildren;
}
const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize;
const isEmpty = !node.isLeaf && isNodeEmpty(node);
if ((hasAnchor || !options.showOnlyCurrent) && isEmpty) {
const emptyNodeClass =
typeof options.emptyNodeClass === "function"
? options.emptyNodeClass({ editor, node, pos, hasAnchor })
: options.emptyNodeClass;
const classes = [emptyNodeClass];
if (isEmptyDoc) {
classes.push(options.emptyEditorClass);
}
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
class: classes.join(" "),
[dataAttribute]:
typeof options.placeholder === "function"
? options.placeholder({ editor, node, pos, hasAnchor })
: options.placeholder,
}),
);
}
return options.includeChildren;
});
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
});
@@ -1,7 +0,0 @@
import { getCollaborationUrl } from "@/lib/config.ts";
const useCollaborationURL = (): string => {
return getCollaborationUrl();
};
export default useCollaborationURL;
+174 -158
View File
@@ -2,20 +2,22 @@ import "@/features/editor/styles/index.css";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
HocuspocusProvider,
onStatusParameters,
WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
onStatelessParameters,
} from "@hocuspocus/provider";
import {
HocuspocusProviderWebsocketComponent,
HocuspocusRoom,
useHocuspocusEvent,
useHocuspocusProvider,
} from "@hocuspocus/provider-react";
import {
Editor,
EditorContent,
@@ -28,7 +30,6 @@ import {
mainExtensions,
} from "@/features/editor/extensions/extensions";
import { useAtom, useAtomValue } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
currentPageEditModeAtom,
@@ -76,6 +77,11 @@ import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import { useTranslation } from "react-i18next";
import {
acquireCollabSocket,
getCollabSocket,
releaseCollabSocket,
} from "@/features/editor/collab-socket";
interface PageEditorProps {
pageId: string;
@@ -91,7 +97,80 @@ export default function PageEditor({
canComment,
}: PageEditorProps) {
const { t } = useTranslation();
const collaborationURL = useCollaborationUrl();
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const [socket] = useState(getCollabSocket);
const hasCollabToken = !!collabQuery?.token;
useEffect(() => {
if (!hasCollabToken) return;
acquireCollabSocket();
return () => releaseCollabSocket();
}, [hasCollabToken]);
const handleStateless = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
updatedAt: message.updatedAt,
...(message.lastUpdatedBy && {
lastUpdatedBy: message.lastUpdatedBy,
}),
});
}
} catch {
// ignore unrelated stateless messages
}
};
const handleAuthenticationFailed = () => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken();
}
};
return (
<TransclusionLookupProvider>
{collabQuery?.token ? (
<HocuspocusProviderWebsocketComponent websocketProvider={socket}>
<HocuspocusRoom
name={`page.${pageId}`}
token={collabQuery.token}
flushDelay={500}
onStateless={handleStateless}
onAuthenticationFailed={handleAuthenticationFailed}
>
<CollabPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
</HocuspocusRoom>
</HocuspocusProviderWebsocketComponent>
) : (
<StaticPageEditor content={content} ariaLabel={t("Page content")} />
)}
</TransclusionLookupProvider>
);
}
function CollabPageEditor({
pageId,
editable,
content,
canComment,
}: PageEditorProps) {
const { t } = useTranslation();
const provider = useHocuspocusProvider();
const isComponentMounted = useRef(false);
const editorRef = useRef<Editor | null>(null);
@@ -112,7 +191,6 @@ export default function PageEditor({
);
const [, setYjsSynced] = useAtom(yjsSyncedAtom);
const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
@@ -123,95 +201,24 @@ export default function PageEditor({
[isComponentMounted],
);
const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
useEffect(() => {
if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setIsLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSynced(event.state);
};
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
updatedAt: message.updatedAt,
...(message.lastUpdatedBy && {
lastUpdatedBy: message.lastUpdatedBy,
}),
});
}
} catch {
// ignore unrelated stateless messages
}
};
const onAuthenticationFailedHandler = () => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
}
};
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
onStateless: onStatelessHandler,
});
local.on("synced", onLocalSyncedHandler);
providersRef.current = { socket, local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
const local = new IndexeddbPersistence(
provider.configuration.name,
provider.document,
);
local.on("synced", () => setIsLocalSynced(true));
return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
local.destroy();
};
}, [pageId]);
}, [provider]);
useHocuspocusEvent("synced", ({ state }) => setIsRemoteSynced(state));
useHocuspocusEvent("status", ({ status }) => setYjsConnectionStatus(status));
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const socket = providersRef.current.socket;
const socket = provider.configuration.websocketProvider;
if (
isIdle &&
@@ -228,23 +235,15 @@ export default function PageEditor({
resetIdle();
socket.connect();
}
}, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
}, [isIdle, documentState, provider, resetIdle]);
const extensions = useMemo(() => {
if (!providersReady || !providersRef.current || !currentUser?.user) {
if (!currentUser?.user) {
return mainExtensions;
}
const remoteProvider = providersRef.current.remote;
return [
...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user),
];
}, [providersReady, currentUser?.user]);
return [...mainExtensions, ...collabExtensions(provider, currentUser.user)];
}, [provider, currentUser?.user]);
const editor = useEditor(
{
@@ -326,6 +325,16 @@ export default function PageEditor({
[pageId, editable, extensions],
);
useLayoutEffect(() => {
if (editor && !editor.isDestroyed) {
// @ts-ignore
setEditor(editor);
// @ts-ignore
editor.storage.pageId = pageId;
editorRef.current = editor;
}
}, [editor, pageId, setEditor]);
const editorIsEditable = useEditorState({
editor,
selector: (ctx) => {
@@ -416,65 +425,72 @@ export default function PageEditor({
}
}, [yjsConnectionStatus, isSynced]);
if (showStatic) {
return <StaticPageEditor content={content} ariaLabel={t("Page content")} />;
}
return (
<TransclusionLookupProvider>
{showStatic ? (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": t("Page content"),
},
}}
/>
) : (
<div className="editor-container" style={{ position: "relative" }}>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
<div className="editor-container" style={{ position: "relative" }}>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
{editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} />
)}
{editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} />
)}
{editor && editorIsEditable && (
<div>
<EditorAiMenu editor={editor} />
<EditorLinkMenu editor={editor} />
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />
<TableHandlesLayer editor={editor} />
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<PdfMenu editor={editor} />
<CalloutMenu editor={editor} />
<SubpagesMenu editor={editor} />
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} />
</div>
)}
{editor &&
!editorIsEditable &&
(editable || canComment) &&
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
{showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} />
)}
{showReadOnlyCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} readOnly />
)}
{editor && editorIsEditable && (
<div>
<EditorAiMenu editor={editor} />
<EditorLinkMenu editor={editor} />
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />
<TableHandlesLayer editor={editor} />
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<PdfMenu editor={editor} />
<CalloutMenu editor={editor} />
<SubpagesMenu editor={editor} />
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} />
</div>
<div
onClick={() => {
if (editor && !editor.isDestroyed) editor.commands.focus("end");
}}
style={{ paddingBottom: "20vh" }}
></div>
</div>
)}
</TransclusionLookupProvider>
)}
{editor && !editorIsEditable && (editable || canComment) && (
<ReadonlyBubbleMenu editor={editor} />
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
{showReadOnlyCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} readOnly />
)}
</div>
<div
onClick={() => {
if (editor && !editor.isDestroyed) editor.commands.focus("end");
}}
style={{ paddingBottom: "20vh" }}
></div>
</div>
);
}
function StaticPageEditor({
content,
ariaLabel,
}: {
content: any;
ariaLabel: string;
}) {
return (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": ariaLabel,
},
}}
/>
);
}
@@ -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"] {
+4
View File
@@ -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,
@@ -1,13 +1,10 @@
import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form";
import { getAppName } from "@/lib/config";
import { Helmet } from "react-helmet-async";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function ForgotPassword() {
return (
<>
<Helmet>
<title>Forgot Password - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Forgot Password" />
<ForgotPasswordForm />
</>
);
+2 -5
View File
@@ -1,16 +1,13 @@
import { Helmet } from "react-helmet-async";
import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form.tsx";
import {getAppName} from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function InviteSignup() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("Invitation Signup")} - {getAppName()}</title>
</Helmet>
<DocumentTitle title={t("Invitation Signup")} />
<InviteSignUpForm />
</>
);
+2 -7
View File
@@ -1,18 +1,13 @@
import { LoginForm } from "@/features/auth/components/login-form";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function LoginPage() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Login")} />
<LoginForm />
</>
);
+3 -12
View File
@@ -1,11 +1,10 @@
import { Helmet } from "react-helmet-async";
import { PasswordResetForm } from "@/features/auth/components/password-reset-form";
import { Link, useSearchParams } from "react-router-dom";
import { useVerifyUserTokenQuery } from "@/features/auth/queries/auth-query";
import { Button, Container, Group, Text } from "@mantine/core";
import APP_ROUTE from "@/lib/app-route";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function PasswordReset() {
const { t } = useTranslation();
@@ -23,11 +22,7 @@ export default function PasswordReset() {
if (isError || !resetToken) {
return (
<>
<Helmet>
<title>
{t("Password Reset")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Password Reset")} />
<Container my={40}>
<Text size="lg" ta="center">
{t("Invalid or expired password reset link")}
@@ -49,11 +44,7 @@ export default function PasswordReset() {
return (
<>
<Helmet>
<title>
{t("Password Reset")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Password Reset")} />
<PasswordResetForm resetToken={resetToken} />
</>
);
@@ -1,11 +1,10 @@
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx";
import { Helmet } from "react-helmet-async";
import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function SetupWorkspace() {
const { t } = useTranslation();
@@ -35,11 +34,7 @@ export default function SetupWorkspace() {
) {
return (
<>
<Helmet>
<title>
{t("Setup Workspace")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Setup Workspace")} />
<SetupWorkspaceForm />
</>
);
+2 -7
View File
@@ -2,20 +2,15 @@ import { Container, Space } from "@mantine/core";
import HomeTabs from "@/features/home/components/home-tabs";
import HomeAiPrompt from "@/features/home/components/home-ai-prompt";
import SpaceCarousel from "@/features/space/components/space-carousel.tsx";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Home() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Home")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Home")} />
<Container size={"900"} pt="xl">
<HomeAiPrompt />
+2 -7
View File
@@ -17,9 +17,7 @@ import {
} from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Helmet } from "react-helmet-async";
import { useDebouncedValue } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import { useLabelPagesQuery } from "@/features/label/queries/label-query.ts";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
@@ -29,6 +27,7 @@ import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu.tsx";
import { EmptyState } from "@/components/ui/empty-state";
import classes from "@/features/label/label.module.css";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function LabelPage() {
const { t } = useTranslation();
@@ -82,11 +81,7 @@ export default function LabelPage() {
return (
<>
<Helmet>
<title>
{labelName} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={labelName} />
<Container size={820} py="xl">
<Stack gap="lg">
+9 -7
View File
@@ -3,7 +3,6 @@ import { usePageQuery } from "@/features/page/queries/page-query";
import { FullEditor } from "@/features/editor/full-editor";
import { TitleEditor } from "@/features/editor/title-editor";
import HistoryModal from "@/features/page-history/components/history-modal";
import { Helmet } from "react-helmet-async";
import PageHeader from "@/features/page/components/header/page-header.tsx";
import { extractPageSlugId } from "@/lib";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
@@ -18,6 +17,7 @@ import { BaseView } from "@/ee/base/components/base-view";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { getPageTitle } from "@/features/page/page.utils";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
const MemoizedFullEditor = React.memo(FullEditor);
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageHeader = React.memo(PageHeader);
@@ -110,9 +110,10 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
paddingTop: "calc(var(--page-header-height) + 6px)",
}}
>
<Helmet>
<title>{`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}</title>
</Helmet>
<DocumentTitle
title={`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}
withAppName={false}
/>
<MemoizedPageHeader readOnly={!canEdit} />
<div
style={{
@@ -159,9 +160,10 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
return (
page && (
<div>
<Helmet>
<title>{`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}</title>
</Helmet>
<DocumentTitle
title={`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}
withAppName={false}
/>
<MemoizedPageHeader readOnly={!canEdit} />
@@ -5,21 +5,16 @@ import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import PageEditPref from "@/features/user/components/page-state-pref";
import FixedToolbarPref from "@/features/user/components/fixed-toolbar-pref";
import NotificationPref from "@/features/user/components/notification-pref";
import { getAppName } from "@/lib/config.ts";
import { Divider } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function AccountPreferences() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Preferences")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Preferences")} />
<SettingsTitle title={t("Preferences")} />
<AccountTheme />
@@ -4,22 +4,17 @@ import ChangePassword from "@/features/user/components/change-password";
import { Divider } from "@mantine/core";
import AccountAvatar from "@/features/user/components/account-avatar";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { AccountMfaSection } from "@/features/user/components/account-mfa-section";
import SessionList from "@/features/session/components/session-list";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function AccountSettings() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("My Profile")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("My Profile")} />
<SettingsTitle title={t("My Profile")} />
<AccountAvatar />
@@ -1,20 +1,15 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import GroupMembersList from "@/features/group/components/group-members";
import GroupDetails from "@/features/group/components/group-details";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function GroupInfo() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Manage Group")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Manage Group")} />
<SettingsTitle title={t("Manage Group")} />
<GroupDetails />
<GroupMembersList />
@@ -3,9 +3,8 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Group } from "@mantine/core";
import CreateGroupModal from "@/features/group/components/create-group-modal";
import useUserRole from "@/hooks/use-user-role.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Groups() {
const { t } = useTranslation();
@@ -13,9 +12,7 @@ export default function Groups() {
return (
<>
<Helmet>
<title>{t("Groups")} - {getAppName()}</title>
</Helmet>
<DocumentTitle title={t("Groups")} />
<SettingsTitle title={t("Groups")} />
<Group my="md" justify="flex-end">
@@ -1,22 +1,17 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import ShareList from "@/features/share/components/share-list.tsx";
import { Alert, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import React from "react";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Shares() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Public sharing")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Public sharing")} />
<SettingsTitle title={t("Public sharing")} />
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
@@ -3,9 +3,8 @@ import SpaceList from "@/features/space/components/space-list.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
import { Group } from "@mantine/core";
import CreateSpaceModal from "@/features/space/components/create-space-modal.tsx";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Spaces() {
const { t } = useTranslation();
@@ -13,11 +12,7 @@ export default function Spaces() {
return (
<>
<Helmet>
<title>
{t("Spaces")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Spaces")} />
<SettingsTitle title={t("Spaces")} />
<Group my="md" justify="flex-end">
@@ -6,11 +6,10 @@ import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function WorkspaceMembers() {
const { t } = useTranslation();
@@ -38,11 +37,7 @@ export default function WorkspaceMembers() {
return (
<>
<Helmet>
<title>
{t("Members")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Members")} />
<SettingsTitle title={t("Members")} />
{/* <WorkspaceInviteSection /> */}
@@ -2,21 +2,19 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
import { useTranslation } from "react-i18next";
import { getAppName, isCloud } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { isCloud } from "@/lib/config.ts";
import ManageHostname from "@/ee/components/manage-hostname.tsx";
import { Divider } from "@mantine/core";
import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx";
import WorkspaceDefaultPageEditMode from "@/features/workspace/components/settings/components/workspace-default-page-edit-mode.tsx";
import PersonalSpacesSetting from "@/ee/personal-space/components/personal-spaces-setting.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function WorkspaceSettings() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>Workspace Settings - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Workspace Settings" />
<SettingsTitle title={t("General")} />
<WorkspaceIcon />
<WorkspaceNameForm />
+6 -4
View File
@@ -1,5 +1,4 @@
import { useNavigate, useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { Container } from "@mantine/core";
@@ -14,6 +13,7 @@ import {
sharedTreeDataAtom,
} from "@/features/share/atoms/shared-page-atom.ts";
import { isPageInTree } from "@/features/share/utils.ts";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function SharedPage() {
const { t } = useTranslation();
@@ -56,12 +56,14 @@ export default function SharedPage() {
return (
<div>
<Helmet>
<title>{`${data?.page?.title || t("untitled")}`}</title>
<DocumentTitle
title={data?.page?.title || t("untitled")}
withAppName={false}
>
{!data?.share.searchIndexing && (
<meta name="robots" content="noindex" />
)}
</Helmet>
</DocumentTitle>
<Container fluid={fullWidth} size={fullWidth ? undefined : 900} p={0}>
<ReadonlyPageEditor
+2 -5
View File
@@ -2,8 +2,7 @@ import {Container} from "@mantine/core";
import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx";
import {useParams} from "react-router-dom";
import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function SpaceHome() {
const {spaceSlug} = useParams();
@@ -11,9 +10,7 @@ export default function SpaceHome() {
return (
<>
<Helmet>
<title>{space?.name || 'Overview'} - {getAppName()}</title>
</Helmet>
<DocumentTitle title={space?.name || 'Overview'} />
<Container size={"900"} pt="xl">
{space && <SpaceHomeTabs/>}
</Container>
+2 -7
View File
@@ -1,13 +1,12 @@
import { Container, Title, Text, Group, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import CreateSpaceModal from "@/features/space/components/create-space-modal";
import { AllSpacesList } from "@/features/space/components/spaces-page";
import FavoriteSpacesGrid from "@/features/space/components/spaces-page/favorite-spaces-grid";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import useUserRole from "@/hooks/use-user-role";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Spaces() {
const { t } = useTranslation();
@@ -22,11 +21,7 @@ export default function Spaces() {
return (
<>
<Helmet>
<title>
{t("Spaces")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Spaces")} />
<Container size={"800"} pt="xl">
<Group justify="space-between" mb="xl">
+3
View File
@@ -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,
+15 -16
View File
@@ -40,30 +40,30 @@
"@clickhouse/client": "1.18.2",
"@docmost/base-formula": "workspace:*",
"@docmost/pdf-inspector": "1.9.6",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@keyv/redis": "^5.1.6",
"@fastify/cookie": "11.0.2",
"@fastify/multipart": "10.0.0",
"@fastify/static": "10.1.2",
"@keyv/redis": "5.1.6",
"@langchain/core": "1.1.46",
"@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "1.29.0",
"@nest-lab/throttler-storage-redis": "^1.2.0",
"@modelcontextprotocol/sdk": "1.30.0",
"@nest-lab/throttler-storage-redis": "1.2.0",
"@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",
@@ -88,11 +89,10 @@
"kysely-migration-cli": "0.4.2",
"kysely-postgres-js": "3.0.0",
"ldapts": "8.1.7",
"lib0": "0.2.117",
"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",
@@ -103,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",
@@ -118,9 +118,8 @@
"stripe": "^17.7.0",
"tlds": "1.261.0",
"tmp-promise": "3.0.3",
"tseep": "1.3.1",
"typesense": "3.0.5",
"undici": "7.28.0",
"undici": "7.29.0",
"ws": "8.21.0",
"yauzl": "3.4.0",
"zod": "4.3.6"
+2
View File
@@ -27,6 +27,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 +54,7 @@ try {
CoreModule,
DatabaseModule,
EnvironmentModule,
EncryptionModule,
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
@@ -15,6 +15,7 @@ import {
RedisSyncExtension,
SerializedHTTPRequest,
} from './extensions/redis-sync';
import { toWebRequest } from './extensions/redis-sync/redis-sync.types';
import { WsSocketWrapper } from './extensions/redis-sync/ws-socket-wrapper';
import RedisClient from 'ioredis';
import { pack, unpack } from 'msgpackr';
@@ -98,34 +99,36 @@ export class CollaborationGateway {
const serializedHTTPRequest = this.serializeRequest(request);
const socketId = serializedHTTPRequest.headers['sec-websocket-key'];
// Create wrapper socket that only receives events via emit()
// This prevents double-handling since Hocuspocus won't listen to raw WebSocket events
const wrappedSocket = new WsSocketWrapper(client);
// Route through RedisSync extension (this calls handleConnection internally)
this.redisSync.onSocketOpen(wrappedSocket as any, serializedHTTPRequest);
this.redisSync.onSocketOpen(wrappedSocket, serializedHTTPRequest);
// Forward raw WebSocket messages to the extension
client.on('message', (data: ArrayBuffer) => {
this.redisSync!.onSocketMessage(
wrappedSocket as any,
serializedHTTPRequest,
data,
);
this.redisSync!.onSocketMessage(serializedHTTPRequest, data);
});
// Forward close events
client.on('close', (code: number, reason: Buffer) => {
this.redisSync!.onSocketClose(socketId, code, reason.buffer as ArrayBuffer);
});
// Forward pong events for keepalive
client.on('pong', (data: Buffer) => {
wrappedSocket.emit('pong', data);
this.redisSync!.onSocketClose(
socketId,
code,
new Uint8Array(reason).buffer,
);
});
} else {
// Fallback to direct Hocuspocus connection
this.hocuspocus.handleConnection(client, request);
const clientConnection = this.hocuspocus.handleConnection(
client,
toWebRequest(this.serializeRequest(request)),
);
client.on('message', (data: Buffer) => {
clientConnection.handleMessage(new Uint8Array(data));
});
client.on('close', (code: number, reason: Buffer) => {
clientConnection.handleClose({ code, reason: reason.toString() });
});
}
}
@@ -178,6 +181,7 @@ export class CollaborationGateway {
if (this.hocuspocus.getDocumentsCount() === 0) resolve('');
this.hocuspocus.closeConnections();
this.hocuspocus.flushPendingStores();
} catch (error) {
console.error(error);
}
@@ -96,7 +96,7 @@ export class PersistenceExtension implements Extension {
}
async onStoreDocument(data: onStoreDocumentPayload) {
const { documentName, document, context } = data;
const { documentName, document, lastContext } = data;
const pageId = getPageId(documentName);
@@ -151,7 +151,7 @@ export class PersistenceExtension implements Extension {
content: tiptapJson,
textContent: textContent,
ydoc: ydocState,
lastUpdatedById: context.user.id,
lastUpdatedById: lastContext.user.id,
contributorIds: contributorIds,
},
pageId,
@@ -169,12 +169,12 @@ export class PersistenceExtension implements Extension {
JSON.stringify({
type: 'page.updated',
updatedAt: new Date().toISOString(),
lastUpdatedById: context?.user?.id,
lastUpdatedBy: context?.user
lastUpdatedById: lastContext?.user?.id,
lastUpdatedBy: lastContext?.user
? {
id: context.user?.id,
name: context.user?.name,
avatarUrl: context.user?.avatarUrl,
id: lastContext.user?.id,
name: lastContext.user?.name,
avatarUrl: lastContext.user?.avatarUrl,
}
: undefined,
}),
@@ -1,61 +1,37 @@
import type RedisClient from 'ioredis';
import { EventEmitter } from 'tseep';
import type {
Pack,
RSAMessageClose,
RSAMessagePing,
RSAMessageSend,
} from './redis-sync.types';
import type { WebSocketLike } from '@hocuspocus/server';
import type { Pack, RSAMessageClose, RSAMessageSend } from './redis-sync.types';
export class CollabProxySocket extends EventEmitter {
// Stands in for the client WebSocket on the server that owns the document.
// Outgoing traffic is relayed over redis to the origin server, which holds the real socket.
export class CollabProxySocket implements WebSocketLike {
private readonly replyTo: string;
private readonly serverChannel: string;
private readonly socketId: string;
private pub: RedisClient;
private readonly pack: Pack;
readyState = 1;
onClose?: (code?: number, reason?: string) => void;
constructor(
pub: RedisClient,
pack: Pack,
replyTo: string,
serverChannel: string,
socketId: string,
) {
super();
constructor(pub: RedisClient, pack: Pack, replyTo: string, socketId: string) {
this.replyTo = replyTo;
this.socketId = socketId;
this.serverChannel = serverChannel;
this.pub = pub;
this.pack = pack;
this.once('close', () => {
this.readyState = 3;
});
}
private publish(msg: RSAMessageClose | RSAMessagePing | RSAMessageSend) {
private publish(msg: RSAMessageClose | RSAMessageSend) {
this.pub.publish(this.replyTo, this.pack(msg));
}
// The origin server already closed the real socket; stop relaying without echoing a close back
markClosed() {
this.readyState = 3;
}
close(code?: number, reason?: string) {
if (this.readyState !== 1) return;
const msg: RSAMessageClose = {
type: 'close',
code,
reason,
socketId: this.socketId,
};
this.publish(msg);
}
ping() {
if (this.readyState !== 1) return;
const msg: RSAMessagePing = {
type: 'ping',
socketId: this.socketId,
replyTo: this.serverChannel,
};
this.publish(msg);
this.readyState = 3;
this.onClose?.(code, reason);
}
send(message: Uint8Array) {
@@ -3,27 +3,30 @@ import {
Extension,
Hocuspocus,
IncomingMessage,
afterUnloadDocumentPayload,
onConfigurePayload,
onLoadDocumentPayload,
afterUnloadDocumentPayload,
WebSocketLike,
} from '@hocuspocus/server';
import { ConnectionTimeout, Unauthorized } from '@hocuspocus/common';
import RedisClient from 'ioredis';
import { readVarString } from 'lib0/decoding.js';
import { CollabProxySocket } from './collab-proxy-socket';
import {
BaseWebSocket,
Configuration,
CustomEvents,
Pack,
RSAMessage,
RSAMessageClose,
RSAMessageCloseProxy,
RSAMessageCustomEventComplete,
RSAMessageCustomEventStart,
RSAMessagePong,
RSAMessageProxy,
RSAMessageUnload,
SerializedHTTPRequest,
Unpack,
OriginConnection,
ProxyConnection,
toWebRequest,
} from './redis-sync.types';
export type { Pack, SerializedHTTPRequest } from './redis-sync.types';
@@ -38,10 +41,10 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
private sub: RedisClient;
private readonly pack: Pack;
private readonly unpack: Unpack;
private originSockets: Record<SocketId, BaseWebSocket> = {};
private originConnections: Record<SocketId, OriginConnection> = {};
private locks: Record<DocumentName, NodeJS.Timeout> = {};
private lockPromises: Record<DocumentName, Promise<ServerId | null>> = {};
private proxySockets: Record<SocketId, CollabProxySocket> = {};
private proxyConnections: Record<SocketId, ProxyConnection> = {};
private readonly prefix: string;
private readonly lockPrefix: string;
private readonly msgChannel: string;
@@ -54,6 +57,9 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
// @ts-ignore
private pendingReplies: Record<number, PromiseWithResolvers<any>['resolve']> =
{};
private deriveContext: (
serializedHTTPRequest: SerializedHTTPRequest,
) => Record<string, any>;
constructor(configuration: Configuration<TCE>) {
const {
@@ -65,6 +71,7 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
prefix,
customEvents,
customEventTTL,
deriveContext,
} = configuration;
this.pub = redis.duplicate();
this.sub = redis.duplicate();
@@ -77,6 +84,7 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
this.lockPrefix = `${this.prefix}Lock`;
this.msgChannel = `${this.prefix}Msg`;
this.customEvents = (customEvents as any) ?? ({} as any as CustomEvents);
this.deriveContext = deriveContext ?? (() => ({}));
this.sub.subscribe(this.msgChannel, `${this.msgChannel}:${this.serverId}`);
this.sub.on('messageBuffer', this.handleRedisMessage);
this.pub.on('error', () => {});
@@ -87,44 +95,63 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
}
private closeProxy(socketId: string) {
const proxySocket = this.proxySockets[socketId];
if (proxySocket) {
proxySocket.emit(
'close',
1000,
Buffer.from('provider_initiated', 'utf-8'),
);
delete this.proxySockets[socketId];
const entry = this.proxyConnections[socketId];
if (entry) {
delete this.proxyConnections[socketId];
const { socket, clientConnection } = entry;
// The origin socket is already gone; don't echo a close message back
socket.markClosed();
clientConnection.handleClose({
code: 1000,
reason: 'provider_initiated',
});
}
}
private pongProxy(socketId: string) {
this.proxySockets[socketId]?.emit('pong');
}
private handleProxyMessage(
msg: Pick<RSAMessageProxy, 'replyTo' | 'message' | 'serializedHTTPRequest'>,
) {
const { replyTo, message, serializedHTTPRequest } = msg;
const { headers } = serializedHTTPRequest;
const socketId = headers['sec-websocket-key']!;
let socket = this.proxySockets[socketId];
if (!socket) {
socket = new CollabProxySocket(
const socketId = headers['sec-websocket-key'];
let entry = this.proxyConnections[socketId];
if (!entry) {
const socket = new CollabProxySocket(
this.pub,
this.pack,
replyTo,
`${this.msgChannel}:${this.serverId}`,
socketId,
);
this.proxySockets[socketId] = socket;
this.instance.handleConnection(
socket as any,
serializedHTTPRequest as any,
{},
// A proxy connection with no live documents (client left the page, auth
// failed, or the origin server crashed) is reaped by hocuspocus' message
// timeout. Dispose it silently in that case: relaying the timeout close
// to the origin would kill the client's real socket, which may be busy
// serving other documents. Genuine protocol closes are still relayed.
socket.onClose = (code, reason) => {
delete this.proxyConnections[socketId];
if (code !== ConnectionTimeout.code) {
const msg: RSAMessageClose = {
type: 'close',
code,
reason,
socketId,
};
this.pub.publish(replyTo, this.pack(msg));
}
};
const clientConnection = this.instance.handleConnection(
socket,
toWebRequest(serializedHTTPRequest),
this.deriveContext(serializedHTTPRequest),
);
entry = { clientConnection, socket };
this.proxyConnections[socketId] = entry;
}
socket.emit('message', message);
entry.clientConnection.handleMessage(message);
}
private getLock(documentName: string) {
return this.pub.get(this.getKey(documentName));
}
private getOrClaimLock(documentName: string) {
@@ -166,10 +193,6 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
this.closeProxy(msg.socketId);
return;
}
if (type === 'pong') {
this.pongProxy(msg.socketId);
return;
}
if (type === 'unload') {
delete this.lockPromises[msg.documentName];
return;
@@ -198,22 +221,14 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
return;
}
const { socketId } = msg;
const socket = this.originSockets[socketId];
if (!socket) {
const entry = this.originConnections[socketId];
if (!entry) {
// origin socket already cleaned up
return;
}
const { socket } = entry;
if (type === 'close') {
socket.close(msg.code, msg.reason);
} else if (type === 'ping') {
// Reply instantly to the proxy socket, without forwarding to client
// The origin socket handles heartbeat for itself
const { replyTo, socketId } = msg;
const reply: RSAMessagePong = {
type: 'pong',
socketId,
};
this.pub.publish(`${replyTo}`, this.pack(reply));
} else if (type === 'send') {
socket.send(msg.message);
}
@@ -251,6 +266,8 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
eventName: TName,
documentName: string,
payload: any,
// if true, don't claim the lock. Useful for targeting pages that are currently open
onlyIfOpen = false,
) {
const isDocLoadedOnInstance = this.instance.documents.has(documentName);
@@ -258,7 +275,14 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
return this.handleEventLocally(eventName, documentName, payload);
}
const proxyTo = await this.getOrClaimLockThrottled(documentName);
const proxyTo = await (onlyIfOpen
? this.getLock(documentName)
: this.getOrClaimLockThrottled(documentName));
if (!proxyTo && onlyIfOpen) {
return;
}
if (proxyTo && proxyTo !== this.serverId) {
++this.replyIdCounter; // bug in biome thinks this.replyIdCounter is not used if written on the line below
const replyId = this.replyIdCounter;
@@ -277,7 +301,8 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
const { promise, resolve, reject } = Promise.withResolvers();
this.pendingReplies[replyId] = resolve;
setTimeout(() => {
reject('TIMEOUT');
delete this.pendingReplies[replyId];
reject(new Error('TIMEOUT'));
}, this.customEventTTL);
return promise as Promise<ReturnType<TCE[TName]>>;
}
@@ -296,36 +321,59 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
/* WebSocket Server Hooks */
onSocketOpen(
ws: BaseWebSocket,
ws: WebSocketLike,
serializedHTTPRequest: SerializedHTTPRequest,
context = {},
) {
const socketId = serializedHTTPRequest.headers['sec-websocket-key']!;
this.originSockets[socketId] = ws;
this.instance.handleConnection(
ws as any,
serializedHTTPRequest as any,
context,
const socketId = serializedHTTPRequest.headers['sec-websocket-key'];
const clientConnection = this.instance.handleConnection(
ws,
toWebRequest(serializedHTTPRequest),
this.deriveContext(serializedHTTPRequest),
);
this.originConnections[socketId] = { clientConnection, socket: ws };
}
async onSocketMessage(
ws: BaseWebSocket,
serializedHTTPRequest: SerializedHTTPRequest,
detachableMsg: ArrayBuffer,
) {
const message = new Uint8Array(detachableMsg.slice());
const tmpMsg = new IncomingMessage(detachableMsg);
const documentName = readVarString(tmpMsg.decoder);
const socketId = serializedHTTPRequest.headers['sec-websocket-key'];
const entry = this.originConnections[socketId];
if (!entry) return;
const { clientConnection } = entry;
let message: Uint8Array;
let documentName: string;
try {
message = new Uint8Array(detachableMsg.slice());
const tmpMsg = new IncomingMessage(detachableMsg);
const documentNameAndSessionId = tmpMsg.readVarString();
// session-aware providers suffix the documentName with \0sessionId
const sepIdx = documentNameAndSessionId.indexOf('\0');
documentName =
sepIdx === -1
? documentNameAndSessionId
: documentNameAndSessionId.slice(0, sepIdx);
} catch (error) {
entry.socket.close(Unauthorized.code, Unauthorized.reason);
return;
}
const isDocLoadedOnInstance = this.instance.documents.has(documentName);
if (isDocLoadedOnInstance) {
ws.emit('message', message);
clientConnection.handleMessage(message);
return;
}
const proxyTo = await this.getOrClaimLockThrottled(documentName);
if (proxyTo && proxyTo !== this.serverId) {
// Proxied messages bypass handleMessage, so refresh the connection's
// liveness fields manually or hocuspocus' message timeout would reap the
// real socket every `timeout` ms. connectionEstablishedAt is the
// reference while unauthenticated (auth for remote docs is proxied too)
// and is private upstream.
clientConnection.lastMessageReceivedAt = Date.now();
(clientConnection as any).connectionEstablishedAt = Date.now();
// another server owns the doc
const proxyMessage: RSAMessageProxy = {
serializedHTTPRequest: serializedHTTPRequest,
@@ -338,16 +386,17 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
return;
}
// This server owns the document, but hocuspocus hasn't loaded it yet
ws.emit('message', message);
clientConnection.handleMessage(message);
}
onSocketClose(socketId: string, code?: number, reason?: ArrayBuffer) {
const socket = this.originSockets[socketId];
if (!socket) return;
// at this point the socket is considered GC'd and we cannot call close
// The origin socket did not set up any connections for the proxy, so none of the hooks will work if we just emit
socket?.emit('close', code, reason);
delete this.originSockets[socketId];
const entry = this.originConnections[socketId];
if (!entry) return;
delete this.originConnections[socketId];
entry.clientConnection.handleClose({
code: code ?? 1000,
reason: reason ? Buffer.from(reason).toString() : '',
});
const msg: RSAMessageCloseProxy = { type: 'closeProxy', socketId };
this.pub.publish(this.msgChannel, this.pack(msg)).catch(() => {});
}
@@ -372,6 +421,7 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
}
async onDestroy() {
this.pendingReplies = {};
this.pub.disconnect(false);
this.sub.disconnect(false);
}
@@ -1,12 +1,13 @@
import EventEmitter from 'node:events';
import { IncomingHttpHeaders } from 'node:http2';
import RedisClient from 'ioredis';
import { CollabProxySocket } from './collab-proxy-socket';
import { type Hocuspocus, type WebSocketLike } from '@hocuspocus/server';
export type SecondParam<T> = T extends (
arg1: unknown,
arg1: any,
arg2: infer A,
...args: unknown[]
) => unknown
...args: any[]
) => any
? A
: never;
@@ -41,17 +42,6 @@ export type RSAMessageClose = {
socketId: string;
};
export type RSAMessagePing = {
type: 'ping';
socketId: string;
replyTo: string;
};
export type RSAMessagePong = {
type: 'pong';
socketId: string;
};
export type RSAMessageSend = {
type: 'send';
// @ts-ignore
@@ -59,7 +49,7 @@ export type RSAMessageSend = {
socketId: string;
};
export type RSAMessageCustomEventStart<TName = string, TPayload = unknown> = {
export type RSAMessageCustomEventStart<TName = string, TPayload = any> = {
type: 'customEventStart';
documentName: string;
eventName: TName;
@@ -71,7 +61,7 @@ export type RSAMessageCustomEventStart<TName = string, TPayload = unknown> = {
export type RSAMessageCustomEventComplete = {
type: 'customEventComplete';
replyId: number;
payload: unknown;
payload: any;
};
export type RSAMessage =
@@ -79,8 +69,6 @@ export type RSAMessage =
| RSAMessageCloseProxy
| RSAMessageUnload
| RSAMessageClose
| RSAMessagePing
| RSAMessagePong
| RSAMessageSend
| RSAMessageCustomEventStart
| RSAMessageCustomEventComplete;
@@ -99,9 +87,20 @@ type CustomEventName = string;
export type CustomEvents = Record<
CustomEventName,
(documentName: string, payload: unknown) => Promise<unknown>
(documentName: string, payload: any) => Promise<any>
>;
// Not exported by @hocuspocus/server
export type ClientConnection = ReturnType<Hocuspocus['handleConnection']>;
export type OriginConnection = {
clientConnection: ClientConnection;
socket: WebSocketLike;
};
export type ProxyConnection = {
clientConnection: ClientConnection;
socket: CollabProxySocket;
};
export interface Configuration<TCE> {
redis: RedisClient;
pack: Pack;
@@ -111,11 +110,29 @@ export interface Configuration<TCE> {
customEventTTL?: number;
prefix?: string;
customEvents?: TCE;
// Derive the hocuspocus context once per socket instead of re-deriving it in a
// per-document hook like onConnect/onAuthenticate. Runs on the origin server when
// the socket opens and on the doc owner when the first proxied message arrives.
deriveContext?: (
serializedHTTPRequest: SerializedHTTPRequest,
) => Record<string, any>;
}
export type BaseWebSocket = EventEmitter & {
readyState: number;
close(code?: number, reason?: string): void;
ping(): void;
send(message: Uint8Array): void;
// Hocuspocus expects a web-standard Request, so rehydrate one from what crossed the wire
export const toWebRequest = (serializedHTTPRequest: SerializedHTTPRequest) => {
const { method, url, headers } = serializedHTTPRequest;
const webHeaders = new Headers();
Object.entries(headers).forEach(([name, value]) => {
if (Array.isArray(value)) {
value.forEach((v) => {
webHeaders.append(name, v);
});
} else if (value !== undefined) {
webHeaders.set(name, value);
}
});
return new Request(new URL(url, 'http://localhost'), {
method,
headers: webHeaders,
});
};
@@ -1,20 +1,17 @@
import { EventEmitter } from 'events';
import type WebSocket from 'ws';
import type { WebSocketLike } from '@hocuspocus/server';
/**
* Wrapper around ws WebSocket that only receives events via emit().
* This prevents double-handling when used with RedisSyncExtension.
* Wrapper around ws WebSocket that Hocuspocus only writes to.
* Incoming socket events are forwarded separately by the gateway,
* which prevents double-handling with RedisSyncExtension.
*/
export class WsSocketWrapper extends EventEmitter {
export class WsSocketWrapper implements WebSocketLike {
private ws: WebSocket;
readyState = 1;
constructor(ws: WebSocket) {
super();
this.ws = ws;
this.once('close', () => {
this.readyState = 3;
});
}
close(code?: number, reason?: string) {
@@ -27,15 +24,6 @@ export class WsSocketWrapper extends EventEmitter {
}
}
ping() {
if (this.readyState !== 1) return;
try {
this.ws.ping();
} catch (e) {
// Socket already closed
}
}
send(message: Uint8Array) {
if (this.readyState !== 1) return;
try {
+1
View File
@@ -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',
@@ -810,6 +810,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;
@@ -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;
}
@@ -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');
}
@@ -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 = [
'',
'',
@@ -31,6 +31,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',
@@ -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>`;
@@ -15,13 +15,21 @@ import { getMimeType } from '../../../common/helpers';
import { Upload } from '@aws-sdk/lib-storage';
import { Logger } from '@nestjs/common';
const S3_MAX_SOCKETS = parseInt(process.env.AWS_S3_MAX_SOCKETS) || 200;
export class S3Driver implements StorageDriver {
private readonly s3Client: S3Client;
private readonly config: S3StorageConfig;
constructor(config: S3StorageConfig) {
this.config = config;
this.s3Client = new S3Client(config as any);
this.config = {
...config,
requestHandler: {
httpAgent: { maxSockets: S3_MAX_SOCKETS },
httpsAgent: { maxSockets: S3_MAX_SOCKETS },
},
};
this.s3Client = new S3Client(this.config as any);
}
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
+39 -82
View File
@@ -23,47 +23,49 @@
"@casl/ability": "6.8.0",
"@docmost/editor-ext": "workspace:*",
"@floating-ui/dom": "1.7.3",
"@hocuspocus/provider": "3.4.4",
"@hocuspocus/server": "3.4.4",
"@hocuspocus/transformer": "3.4.4",
"@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",
"@tiptap/core": "3.27.1",
"@tiptap/extension-audio": "3.27.1",
"@tiptap/extension-code-block": "3.27.1",
"@tiptap/extension-collaboration": "3.27.1",
"@tiptap/extension-collaboration-caret": "3.27.1",
"@tiptap/extension-color": "3.27.1",
"@tiptap/extension-document": "3.27.1",
"@tiptap/extension-heading": "3.27.1",
"@tiptap/extension-highlight": "3.27.1",
"@tiptap/extension-history": "3.27.1",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-link": "3.27.1",
"@tiptap/extension-list": "3.27.1",
"@tiptap/extension-placeholder": "3.27.1",
"@tiptap/extension-subscript": "3.27.1",
"@tiptap/extension-superscript": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/extension-text": "3.27.1",
"@tiptap/extension-text-align": "3.27.1",
"@tiptap/extension-text-style": "3.27.1",
"@tiptap/extension-typography": "3.27.1",
"@tiptap/extension-unique-id": "3.27.1",
"@tiptap/extension-youtube": "3.27.1",
"@tiptap/html": "3.27.1",
"@tiptap/pm": "3.27.1",
"@tiptap/react": "3.27.1",
"@tiptap/starter-kit": "3.27.1",
"@tiptap/suggestion": "3.27.1",
"@tiptap/y-tiptap": "3.0.5",
"@tiptap/core": "3.29.2",
"@tiptap/extension-audio": "3.29.2",
"@tiptap/extension-code-block": "3.29.2",
"@tiptap/extension-collaboration": "3.29.2",
"@tiptap/extension-collaboration-caret": "3.29.2",
"@tiptap/extension-color": "3.29.2",
"@tiptap/extension-document": "3.29.2",
"@tiptap/extension-heading": "3.29.2",
"@tiptap/extension-highlight": "3.29.2",
"@tiptap/extension-history": "3.29.2",
"@tiptap/extension-image": "3.29.2",
"@tiptap/extension-link": "3.29.2",
"@tiptap/extension-list": "3.29.2",
"@tiptap/extension-placeholder": "3.29.2",
"@tiptap/extension-subscript": "3.29.2",
"@tiptap/extension-superscript": "3.29.2",
"@tiptap/extension-table": "3.29.2",
"@tiptap/extension-text": "3.29.2",
"@tiptap/extension-text-align": "3.29.2",
"@tiptap/extension-text-style": "3.29.2",
"@tiptap/extension-typography": "3.29.2",
"@tiptap/extension-unique-id": "3.29.2",
"@tiptap/extension-youtube": "3.29.2",
"@tiptap/html": "3.29.2",
"@tiptap/pm": "3.29.2",
"@tiptap/react": "3.29.2",
"@tiptap/starter-kit": "3.29.2",
"@tiptap/suggestion": "3.29.2",
"@tiptap/y-tiptap": "3.0.7",
"bytes": "3.1.2",
"cross-env": "10.1.0",
"date-fns": "4.1.0",
"diff": "8.0.3",
"docx": "9.7.1",
"dompurify": "3.4.11",
"dompurify": "3.4.13",
"fractional-indexing-jittered": "1.0.0",
"highlight.js": "11.11.1",
"image-dimensions": "2.5.0",
@@ -79,12 +81,12 @@
"yjs": "^13.6.30"
},
"devDependencies": {
"@nx/js": "22.6.1",
"@nx/js": "23.1.1",
"@types/bytes": "3.1.5",
"@types/qrcode": "1.5.6",
"@types/turndown": "5.0.6",
"concurrently": "9.2.3",
"nx": "22.6.1",
"concurrently": "10.0.4",
"nx": "23.1.1",
"tsx": "^4.21.0"
},
"workspaces": {
@@ -93,50 +95,5 @@
"packages/*"
]
},
"packageManager": "pnpm@10.4.0",
"pnpm": {
"patchedDependencies": {
"scimmy@1.3.5": "patches/scimmy@1.3.5.patch"
},
"overrides": {
"prosemirror-changeset": "2.4.0",
"y-prosemirror": "1.3.7",
"glob": "13.0.6",
"ws": "8.21.0",
"dompurify": "3.4.11",
"tmp": "0.2.7",
"hono": "4.12.25",
"mermaid": "11.15.0",
"nanoid@^3": "3.3.8",
"socket.io-parser": "4.2.6",
"serialize-javascript": "7.0.3",
"lodash-es": "4.18.1",
"lodash": "4.18.1",
"@hono/node-server": "1.19.13",
"undici": "7.28.0",
"ajv@^6": "6.14.0",
"ajv@^8": "8.18.0",
"underscore": "1.13.8",
"immutable": "4.3.8",
"express-rate-limit": "8.2.2",
"minimatch@^3": "3.1.5",
"minimatch@^5": "5.1.8",
"flatted": "3.4.2",
"picomatch@<2.3.2": "2.3.2",
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
"fastify": "5.8.5",
"yaml@>=1.0.0 <1.10.3": "1.10.3",
"yaml@>=2.0.0 <2.8.3": "2.8.3",
"path-to-regexp@^8": "8.4.0",
"brace-expansion@^5": "5.0.6",
"@xmldom/xmldom": "0.8.13",
"handlebars": "4.7.9",
"axios": "1.16.0",
"langsmith": "0.7.0",
"follow-redirects": "1.16.0",
"protobufjs": "7.5.8",
"ip-address": "10.1.1"
},
"neverBuiltDependencies": []
}
"packageManager": "pnpm@11.15.1"
}
@@ -422,6 +422,8 @@ export const SearchAndReplace = Extension.create<
state: {
init: () => DecorationSet.empty,
apply({ doc, docChanged }, oldState) {
const storage = editor.storage.searchAndReplace;
if (!storage) return oldState;
const {
searchTerm,
lastSearchTerm,
@@ -429,7 +431,7 @@ export const SearchAndReplace = Extension.create<
lastCaseSensitive,
resultIndex,
lastResultIndex,
} = editor.storage.searchAndReplace;
} = storage;
if (
!docChanged &&
+1 -1
View File
@@ -19,7 +19,7 @@ export const TableCell = TiptapTableCell.extend({
return {};
}
return {
style: `background-color: ${attributes.backgroundColor}`,
style: `background-color: ${attributes.backgroundColor}; --cell-bg: ${attributes.backgroundColor}`,
"data-background-color": attributes.backgroundColor,
};
},

Some files were not shown because too many files have changed in this diff Show More