mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23cddb78b7 | ||
|
|
f05a611910 | ||
|
|
5792fc7ca2 | ||
|
|
949072744d | ||
|
|
0a87db4f1f |
@@ -22,7 +22,6 @@
|
|||||||
"Can view": "Can view",
|
"Can view": "Can view",
|
||||||
"Can view pages in space but not edit.": "Can view pages in space but not edit.",
|
"Can view pages in space but not edit.": "Can view pages in space but not edit.",
|
||||||
"Cancel": "Cancel",
|
"Cancel": "Cancel",
|
||||||
"Cancelled": "Cancelled",
|
|
||||||
"Change email": "Change email",
|
"Change email": "Change email",
|
||||||
"Change password": "Change password",
|
"Change password": "Change password",
|
||||||
"Change photo": "Change photo",
|
"Change photo": "Change photo",
|
||||||
@@ -30,9 +29,7 @@
|
|||||||
"Choose your preferred color scheme.": "Choose your preferred color scheme.",
|
"Choose your preferred color scheme.": "Choose your preferred color scheme.",
|
||||||
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
||||||
"Choose your preferred page width.": "Choose your preferred page width.",
|
"Choose your preferred page width.": "Choose your preferred page width.",
|
||||||
"Completed": "Completed",
|
|
||||||
"Confirm": "Confirm",
|
"Confirm": "Confirm",
|
||||||
"Confluence site": "Confluence site",
|
|
||||||
"Copy as Markdown": "Copy as Markdown",
|
"Copy as Markdown": "Copy as Markdown",
|
||||||
"Copy link": "Copy link",
|
"Copy link": "Copy link",
|
||||||
"Create": "Create",
|
"Create": "Create",
|
||||||
@@ -60,10 +57,6 @@
|
|||||||
"e.g Space for product team": "e.g. Space for product team",
|
"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",
|
"e.g Space for sales team to collaborate": "e.g. Space for sales team to collaborate",
|
||||||
"Edit": "Edit",
|
"Edit": "Edit",
|
||||||
"Everyone with access to this space": "Everyone with access to this space",
|
|
||||||
"Failed": "Failed",
|
|
||||||
"Import details": "Import details",
|
|
||||||
"Permissions": "Permissions",
|
|
||||||
"Read": "Read",
|
"Read": "Read",
|
||||||
"Edit group": "Edit group",
|
"Edit group": "Edit group",
|
||||||
"Email": "Email",
|
"Email": "Email",
|
||||||
@@ -83,7 +76,6 @@
|
|||||||
"Failed to restore page": "Failed to restore page",
|
"Failed to restore page": "Failed to restore page",
|
||||||
"Failed to fetch recent pages": "Failed to fetch recent pages",
|
"Failed to fetch recent pages": "Failed to fetch recent pages",
|
||||||
"Failed to import pages": "Failed to import 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 load page. An error occurred.": "Failed to load page. An error occurred.",
|
||||||
"Failed to update data": "Failed to update data",
|
"Failed to update data": "Failed to update data",
|
||||||
"Failed to create base": "Failed to create base",
|
"Failed to create base": "Failed to create base",
|
||||||
@@ -158,9 +150,6 @@
|
|||||||
"page": "page",
|
"page": "page",
|
||||||
"Page deleted successfully": "Page deleted successfully",
|
"Page deleted successfully": "Page deleted successfully",
|
||||||
"Page history": "Page history",
|
"Page history": "Page history",
|
||||||
"Restricted pages": "Restricted pages",
|
|
||||||
"Restrictions": "Restrictions",
|
|
||||||
"Running": "Running",
|
|
||||||
"Select version": "Select version",
|
"Select version": "Select version",
|
||||||
"Highlight changes": "Highlight changes",
|
"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.",
|
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
|
||||||
@@ -197,7 +186,6 @@
|
|||||||
"Setup workspace": "Setup workspace",
|
"Setup workspace": "Setup workspace",
|
||||||
"Sign In": "Sign In",
|
"Sign In": "Sign In",
|
||||||
"Sign Up": "Sign Up",
|
"Sign Up": "Sign Up",
|
||||||
"Site": "Site",
|
|
||||||
"Slug": "Slug",
|
"Slug": "Slug",
|
||||||
"Space": "Space",
|
"Space": "Space",
|
||||||
"Space description": "Space description",
|
"Space description": "Space description",
|
||||||
@@ -206,13 +194,10 @@
|
|||||||
"Space settings": "Space settings",
|
"Space settings": "Space settings",
|
||||||
"Space slug": "Space slug",
|
"Space slug": "Space slug",
|
||||||
"Spaces": "Spaces",
|
"Spaces": "Spaces",
|
||||||
"spaces": "spaces",
|
|
||||||
"Spaces you belong to": "Spaces you belong to",
|
"Spaces you belong to": "Spaces you belong to",
|
||||||
"No space found": "No space found",
|
"No space found": "No space found",
|
||||||
"Search for spaces": "Search for spaces",
|
"Search for spaces": "Search for spaces",
|
||||||
"Start typing to search...": "Start typing to search...",
|
"Start typing to search...": "Start typing to search...",
|
||||||
"Started at": "Started at",
|
|
||||||
"Started by": "Started by",
|
|
||||||
"Status": "Status",
|
"Status": "Status",
|
||||||
"Successfully imported": "Successfully imported",
|
"Successfully imported": "Successfully imported",
|
||||||
"Successfully restored": "Successfully restored",
|
"Successfully restored": "Successfully restored",
|
||||||
@@ -226,8 +211,6 @@
|
|||||||
"Untitled": "Untitled",
|
"Untitled": "Untitled",
|
||||||
"Updated successfully": "Updated successfully",
|
"Updated successfully": "Updated successfully",
|
||||||
"User": "User",
|
"User": "User",
|
||||||
"Users": "Users",
|
|
||||||
"users": "users",
|
|
||||||
"Workspace": "Workspace",
|
"Workspace": "Workspace",
|
||||||
"Workspace Name": "Workspace Name",
|
"Workspace Name": "Workspace Name",
|
||||||
"Workspace settings": "Workspace settings",
|
"Workspace settings": "Workspace settings",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
||||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||||
|
|
||||||
|
|
||||||
const SetupWorkspace = lazy(() => import("@/pages/auth/setup-workspace.tsx"));
|
const SetupWorkspace = lazy(() => import("@/pages/auth/setup-workspace.tsx"));
|
||||||
const LoginPage = lazy(() => import("@/pages/auth/login"));
|
const LoginPage = lazy(() => import("@/pages/auth/login"));
|
||||||
const Home = lazy(() => import("@/pages/dashboard/home"));
|
const Home = lazy(() => import("@/pages/dashboard/home"));
|
||||||
@@ -83,9 +84,6 @@ const AiChat = lazy(() => import("@/ee/ai-chat/pages/ai-chat.tsx"));
|
|||||||
const VerifyEmail = lazy(() => import("@/ee/pages/verify-email.tsx"));
|
const VerifyEmail = lazy(() => import("@/ee/pages/verify-email.tsx"));
|
||||||
const LabelPage = lazy(() => import("@/pages/label/label-page"));
|
const LabelPage = lazy(() => import("@/pages/label/label-page"));
|
||||||
const OAuthConsent = lazy(() => import("@/ee/oauth/pages/oauth-consent.tsx"));
|
const OAuthConsent = lazy(() => import("@/ee/oauth/pages/oauth-consent.tsx"));
|
||||||
const ConfluenceImportPage = lazy(
|
|
||||||
() => import("@/ee/confluence-import/pages/confluence-import.tsx"),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -192,10 +190,6 @@ export default function App() {
|
|||||||
element={<Navigate to="/settings/audit/siem" replace />}
|
element={<Navigate to="/settings/audit/siem" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path={"verifications"} element={<VerifiedPages />} />
|
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||||
<Route
|
|
||||||
path={"import/confluence"}
|
|
||||||
element={<ConfluenceImportPage />}
|
|
||||||
/>
|
|
||||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
|
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
|
||||||
import { IconX } from "@tabler/icons-react";
|
import { IconX } from "@tabler/icons-react";
|
||||||
import { CommentErrorBoundary } from "@/features/comment/components/comment-error-boundary.tsx";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import React, { lazy, ReactNode, Suspense, useEffect } from "react";
|
import React, { lazy, ReactNode, Suspense, useEffect } from "react";
|
||||||
@@ -42,11 +41,7 @@ export default function Aside() {
|
|||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "comments":
|
case "comments":
|
||||||
component = (
|
component = <CommentListWithTabs />;
|
||||||
<CommentErrorBoundary>
|
|
||||||
<CommentListWithTabs />
|
|
||||||
</CommentErrorBoundary>
|
|
||||||
);
|
|
||||||
title = "Comments";
|
title = "Comments";
|
||||||
break;
|
break;
|
||||||
case "toc":
|
case "toc":
|
||||||
|
|||||||
@@ -15,12 +15,11 @@ import {
|
|||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
IconFileImport,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { isBetaConfluenceImporter, isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
|
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
|
||||||
@@ -52,7 +51,6 @@ type DataItem = {
|
|||||||
feature?: string;
|
feature?: string;
|
||||||
role?: "admin" | "owner";
|
role?: "admin" | "owner";
|
||||||
env?: "cloud" | "selfhosted";
|
env?: "cloud" | "selfhosted";
|
||||||
show?: () => boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type DataGroup = {
|
type DataGroup = {
|
||||||
@@ -127,15 +125,6 @@ const groupedData: DataGroup[] = [
|
|||||||
role: "owner",
|
role: "owner",
|
||||||
env: "selfhosted",
|
env: "selfhosted",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Import",
|
|
||||||
icon: IconFileImport,
|
|
||||||
path: "/settings/import/confluence",
|
|
||||||
feature: Feature.CONFLUENCE_API_IMPORT,
|
|
||||||
role: "admin",
|
|
||||||
env: "selfhosted",
|
|
||||||
show: () => isBetaConfluenceImporter(),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -169,7 +158,6 @@ export default function SettingsSidebar() {
|
|||||||
entitlements?.features?.includes(f) ?? false;
|
entitlements?.features?.includes(f) ?? false;
|
||||||
|
|
||||||
const canShowItem = (item: DataItem) => {
|
const canShowItem = (item: DataItem) => {
|
||||||
if (item.show && !item.show()) return false;
|
|
||||||
if (item.env === "cloud" && !isCloud()) return false;
|
if (item.env === "cloud" && !isCloud()) return false;
|
||||||
if (item.env === "selfhosted" && isCloud()) return false;
|
if (item.env === "selfhosted" && isCloud()) return false;
|
||||||
if (item.role === "admin" && !isAdmin) return false;
|
if (item.role === "admin" && !isAdmin) return false;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type FieldProps = {
|
|||||||
rowId: string;
|
rowId: string;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
onChange: (value: unknown) => void;
|
onChange: (value: unknown) => void;
|
||||||
|
onEditingChange?: (editing: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FieldShellProps = {
|
type FieldShellProps = {
|
||||||
@@ -99,9 +100,10 @@ type DetailFieldProps = {
|
|||||||
row: IBaseRow;
|
row: IBaseRow;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
onUpdate: (propertyId: string, value: unknown) => void;
|
onUpdate: (propertyId: string, value: unknown) => void;
|
||||||
|
onEditingChange: (editing: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldProps) {
|
export function DetailField({ property, row, readOnly, onUpdate, onEditingChange }: DetailFieldProps) {
|
||||||
const descriptor = getDescriptor(property.type);
|
const descriptor = getDescriptor(property.type);
|
||||||
const value = descriptor?.systemAccessor
|
const value = descriptor?.systemAccessor
|
||||||
? descriptor.systemAccessor(row)
|
? descriptor.systemAccessor(row)
|
||||||
@@ -112,6 +114,7 @@ export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldPr
|
|||||||
rowId: row.id,
|
rowId: row.id,
|
||||||
readOnly,
|
readOnly,
|
||||||
onChange: (next: unknown) => onUpdate(property.id, next),
|
onChange: (next: unknown) => onUpdate(property.id, next),
|
||||||
|
onEditingChange
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (property.type) {
|
switch (property.type) {
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ const normalize = (s: string) => {
|
|||||||
return trimmed.length ? trimmed : null;
|
return trimmed.length ? trimmed : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FieldLongText({ property, value, readOnly, onChange }: FieldProps) {
|
export function FieldLongText({
|
||||||
|
property,
|
||||||
|
value,
|
||||||
|
readOnly,
|
||||||
|
onChange,
|
||||||
|
onEditingChange,
|
||||||
|
}: FieldProps) {
|
||||||
const text = toText(value);
|
const text = toText(value);
|
||||||
const [draft, setDraft] = useState(text);
|
const [draft, setDraft] = useState(text);
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
@@ -23,6 +29,7 @@ export function FieldLongText({ property, value, readOnly, onChange }: FieldProp
|
|||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
setFocused(false);
|
setFocused(false);
|
||||||
|
onEditingChange?.(false);
|
||||||
if (cancelRef.current) {
|
if (cancelRef.current) {
|
||||||
cancelRef.current = false;
|
cancelRef.current = false;
|
||||||
setDraft(text);
|
setDraft(text);
|
||||||
@@ -50,7 +57,10 @@ export function FieldLongText({ property, value, readOnly, onChange }: FieldProp
|
|||||||
className={classes.fieldTextarea}
|
className={classes.fieldTextarea}
|
||||||
classNames={{ input: classes.fieldTextareaInput }}
|
classNames={{ input: classes.fieldTextareaInput }}
|
||||||
value={draft}
|
value={draft}
|
||||||
onFocus={() => setFocused(true)}
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
onEditingChange?.(true);
|
||||||
|
}}
|
||||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
|||||||
const toDraft = (value: unknown) =>
|
const toDraft = (value: unknown) =>
|
||||||
typeof value === "number" ? String(value) : "";
|
typeof value === "number" ? String(value) : "";
|
||||||
|
|
||||||
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
|
export function FieldNumber({
|
||||||
|
property,
|
||||||
|
value,
|
||||||
|
readOnly,
|
||||||
|
onChange,
|
||||||
|
onEditingChange,
|
||||||
|
}: FieldProps) {
|
||||||
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
|
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
|
||||||
const numValue = typeof value === "number" ? value : null;
|
const numValue = typeof value === "number" ? value : null;
|
||||||
const [draft, setDraft] = useState(toDraft(value));
|
const [draft, setDraft] = useState(toDraft(value));
|
||||||
@@ -36,6 +42,7 @@ export function FieldNumber({ property, value, readOnly, onChange }: FieldProps)
|
|||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
setFocused(false);
|
setFocused(false);
|
||||||
|
onEditingChange?.(false);
|
||||||
if (cancelRef.current) {
|
if (cancelRef.current) {
|
||||||
cancelRef.current = false;
|
cancelRef.current = false;
|
||||||
setDraft(toDraft(value));
|
setDraft(toDraft(value));
|
||||||
@@ -54,6 +61,7 @@ export function FieldNumber({ property, value, readOnly, onChange }: FieldProps)
|
|||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
setDraft(toDraft(value));
|
setDraft(toDraft(value));
|
||||||
setFocused(true);
|
setFocused(true);
|
||||||
|
onEditingChange?.(true);
|
||||||
}}
|
}}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
|||||||
|
|
||||||
const toText = (value: unknown) => (typeof value === "string" ? value : "");
|
const toText = (value: unknown) => (typeof value === "string" ? value : "");
|
||||||
|
|
||||||
export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
|
export function FieldText({
|
||||||
|
property,
|
||||||
|
value,
|
||||||
|
readOnly,
|
||||||
|
onChange,
|
||||||
|
onEditingChange,
|
||||||
|
}: FieldProps) {
|
||||||
const text = toText(value);
|
const text = toText(value);
|
||||||
const [draft, setDraft] = useState(text);
|
const [draft, setDraft] = useState(text);
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
@@ -20,6 +26,7 @@ export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
|
|||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
setFocused(false);
|
setFocused(false);
|
||||||
|
onEditingChange?.(false);
|
||||||
if (cancelRef.current) {
|
if (cancelRef.current) {
|
||||||
cancelRef.current = false;
|
cancelRef.current = false;
|
||||||
setDraft(text);
|
setDraft(text);
|
||||||
@@ -54,7 +61,10 @@ export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
|
|||||||
className={classes.fieldInput}
|
className={classes.fieldInput}
|
||||||
value={draft}
|
value={draft}
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
onFocus={() => setFocused(true)}
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
onEditingChange?.(true);
|
||||||
|
}}
|
||||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type PropertyRowProps = {
|
|||||||
onMenuOpenChange: (opened: boolean) => void;
|
onMenuOpenChange: (opened: boolean) => void;
|
||||||
onMenuDirtyChange: (dirty: boolean) => void;
|
onMenuDirtyChange: (dirty: boolean) => void;
|
||||||
onUpdate: (propertyId: string, value: unknown) => void;
|
onUpdate: (propertyId: string, value: unknown) => void;
|
||||||
|
onEditingChange?: (editing: boolean) => void;
|
||||||
autoFocusValue?: boolean;
|
autoFocusValue?: boolean;
|
||||||
onAutoFocused?: () => void;
|
onAutoFocused?: () => void;
|
||||||
};
|
};
|
||||||
@@ -29,6 +30,7 @@ export function PropertyRow({
|
|||||||
onMenuOpenChange,
|
onMenuOpenChange,
|
||||||
onMenuDirtyChange,
|
onMenuDirtyChange,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
onEditingChange,
|
||||||
autoFocusValue,
|
autoFocusValue,
|
||||||
onAutoFocused,
|
onAutoFocused,
|
||||||
}: PropertyRowProps) {
|
}: PropertyRowProps) {
|
||||||
@@ -112,6 +114,7 @@ export function PropertyRow({
|
|||||||
row={row}
|
row={row}
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
|
onEditingChange={onEditingChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function RowDetailModal({
|
|||||||
|
|
||||||
const isSaving = updateRowMutation.isPending;
|
const isSaving = updateRowMutation.isPending;
|
||||||
const opened = !!openRowId;
|
const opened = !!openRowId;
|
||||||
|
const [editingField, setEditingField] = useState(false);
|
||||||
|
|
||||||
// One field menu open at a time, mirroring the grid header's semantics.
|
// One field menu open at a time, mirroring the grid header's semantics.
|
||||||
// The shared closeRequest atom asks an open dirty PropertyMenuContent to
|
// The shared closeRequest atom asks an open dirty PropertyMenuContent to
|
||||||
@@ -90,6 +91,7 @@ export function RowDetailModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpenMenuId(null);
|
setOpenMenuId(null);
|
||||||
menuDirtyRef.current = false;
|
menuDirtyRef.current = false;
|
||||||
|
setEditingField(false);
|
||||||
}, [openRowId]);
|
}, [openRowId]);
|
||||||
|
|
||||||
const handleMenuDirtyChange = useCallback((dirty: boolean) => {
|
const handleMenuDirtyChange = useCallback((dirty: boolean) => {
|
||||||
@@ -293,7 +295,7 @@ export function RowDetailModal({
|
|||||||
row={row}
|
row={row}
|
||||||
primaryProperty={primaryProperty}
|
primaryProperty={primaryProperty}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
onClose={onClose}
|
onEditingChange={setEditingField}
|
||||||
onCommit={(value) => {
|
onCommit={(value) => {
|
||||||
if (!primaryProperty) return;
|
if (!primaryProperty) return;
|
||||||
updateRowMutation.mutate({
|
updateRowMutation.mutate({
|
||||||
@@ -317,6 +319,7 @@ export function RowDetailModal({
|
|||||||
autoFocusValue={property.id === newPropertyId}
|
autoFocusValue={property.id === newPropertyId}
|
||||||
onAutoFocused={clearNewProperty}
|
onAutoFocused={clearNewProperty}
|
||||||
menuOpened={openMenuId === property.id}
|
menuOpened={openMenuId === property.id}
|
||||||
|
onEditingChange={setEditingField}
|
||||||
onMenuOpenChange={(nextOpened) =>
|
onMenuOpenChange={(nextOpened) =>
|
||||||
handleMenuOpenChange(property.id, nextOpened)
|
handleMenuOpenChange(property.id, nextOpened)
|
||||||
}
|
}
|
||||||
@@ -367,16 +370,38 @@ export function RowDetailModal({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.kbdHint}>
|
<div className={classes.kbdHint}>
|
||||||
{rowIndex >= 0 && rows.length > 1 && (
|
{editingField ? (
|
||||||
<>
|
<>
|
||||||
<kbd className={classes.kbd}>↑</kbd>
|
<span className={classes.kbdGroup}>
|
||||||
<kbd className={classes.kbd}>↓</kbd>
|
<kbd className={classes.kbd}>Ctrl/Cmd</kbd>
|
||||||
<span>{t("to navigate")}</span>
|
<span className={classes.kbdPlus} >+</span>
|
||||||
|
<kbd className={classes.kbd}>Enter</kbd>
|
||||||
|
<span>{t("to save")}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
<span className={classes.kbdSeparator} />
|
<span className={classes.kbdSeparator} />
|
||||||
|
|
||||||
|
<span className={classes.kbdGroup}>
|
||||||
|
<kbd className={classes.kbd}>Esc</kbd>
|
||||||
|
<span>{t("to reset")}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{rowIndex >= 0 && rows.length > 1 && (
|
||||||
|
<>
|
||||||
|
<kbd className={classes.kbd}>↑</kbd>
|
||||||
|
<kbd className={classes.kbd}>↓</kbd>
|
||||||
|
<span>{t("to navigate")}</span>
|
||||||
|
<span className={classes.kbdSeparator} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<>
|
||||||
|
<kbd className={classes.kbd}>Esc</kbd>
|
||||||
|
<span>{t("to close")}</span>
|
||||||
|
</>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<kbd className={classes.kbd}>Esc</kbd>
|
|
||||||
<span>{t("to close")}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
||||||
import { timeAgo } from "@/lib/time.ts";
|
import { timeAgo } from "@/lib/time.ts";
|
||||||
@@ -9,7 +9,7 @@ type RowDetailTitleProps = {
|
|||||||
primaryProperty: IBaseProperty | undefined;
|
primaryProperty: IBaseProperty | undefined;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
onCommit: (value: string) => void;
|
onCommit: (value: string) => void;
|
||||||
onClose: () => void;
|
onEditingChange?: (editing: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RowDetailTitle({
|
export function RowDetailTitle({
|
||||||
@@ -17,13 +17,24 @@ export function RowDetailTitle({
|
|||||||
primaryProperty,
|
primaryProperty,
|
||||||
canEdit,
|
canEdit,
|
||||||
onCommit,
|
onCommit,
|
||||||
onClose,
|
onEditingChange,
|
||||||
}: RowDetailTitleProps) {
|
}: RowDetailTitleProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const initial = primaryProperty
|
const initial = primaryProperty
|
||||||
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
|
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
|
||||||
: "";
|
: "";
|
||||||
const [value, setValue] = useState(initial);
|
const [value, setValue] = useState(initial);
|
||||||
|
const cancelRef = useRef(false);
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
onEditingChange?.(false);
|
||||||
|
if (cancelRef.current) {
|
||||||
|
cancelRef.current = false;
|
||||||
|
setValue(initial);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value !== initial) onCommit(value);
|
||||||
|
};
|
||||||
|
|
||||||
// Re-sync when the row changes underneath us (navigation or remote edit).
|
// Re-sync when the row changes underneath us (navigation or remote edit).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,18 +54,18 @@ export function RowDetailTitle({
|
|||||||
aria-label={primaryProperty?.name ?? t("Untitled")}
|
aria-label={primaryProperty?.name ?? t("Untitled")}
|
||||||
value={value}
|
value={value}
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
onChange={(e) => setValue(e.currentTarget.value)}
|
onFocus={() => {
|
||||||
onBlur={() => {
|
onEditingChange?.(true);
|
||||||
if (value !== initial) onCommit(value);
|
|
||||||
}}
|
}}
|
||||||
|
onChange={(e) => setValue(e.currentTarget.value)}
|
||||||
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Escape") {
|
||||||
|
cancelRef.current = true;
|
||||||
|
e.currentTarget.blur();
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
(e.currentTarget as HTMLInputElement).blur();
|
e.currentTarget.blur();
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
(e.currentTarget as HTMLInputElement).blur();
|
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -416,9 +416,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.kbdHint {
|
.kbdHint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbdGroup {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbdPlus {
|
||||||
|
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||||
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kbdSeparator {
|
.kbdSeparator {
|
||||||
|
|||||||
@@ -1,340 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
Progress,
|
|
||||||
Skeleton,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconAlertCircle, IconCheck, IconX } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ConfluenceImportHistoryItem,
|
|
||||||
ConfluenceImportStatus,
|
|
||||||
} from "@/ee/confluence-import/types/confluence-import.types";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
|
||||||
import { formattedDate } from "@/lib/time";
|
|
||||||
import NoTableResults from "@/components/common/no-table-results";
|
|
||||||
import { useConfluenceImportsQuery } from "@/ee/confluence-import/queries/confluence-import-queries";
|
|
||||||
|
|
||||||
const BADGE_STYLES = {
|
|
||||||
root: { flexShrink: 0 },
|
|
||||||
label: { overflow: "visible" as const },
|
|
||||||
};
|
|
||||||
|
|
||||||
function statusBadge(
|
|
||||||
status: ConfluenceImportStatus,
|
|
||||||
cancelled: boolean,
|
|
||||||
t: (key: string) => string,
|
|
||||||
) {
|
|
||||||
if (cancelled) {
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
color="gray"
|
|
||||||
variant="light"
|
|
||||||
leftSection={<IconX size={12} />}
|
|
||||||
styles={BADGE_STYLES}
|
|
||||||
>
|
|
||||||
{t("Cancelled")}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (status === "processing") {
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
color="blue"
|
|
||||||
variant="light"
|
|
||||||
leftSection={<Loader size={10} />}
|
|
||||||
styles={BADGE_STYLES}
|
|
||||||
>
|
|
||||||
{t("Running")}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (status === "success") {
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
color="teal"
|
|
||||||
variant="light"
|
|
||||||
leftSection={<IconCheck size={12} />}
|
|
||||||
styles={BADGE_STYLES}
|
|
||||||
>
|
|
||||||
{t("Completed")}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
leftSection={<IconAlertCircle size={12} />}
|
|
||||||
styles={BADGE_STYLES}
|
|
||||||
>
|
|
||||||
{t("Failed")}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function phaseLabel(phase: string | null, t: (key: string) => string): string {
|
|
||||||
if (!phase) return "—";
|
|
||||||
return t(phase.charAt(0).toUpperCase() + phase.slice(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
function progressValue(item: ConfluenceImportHistoryItem) {
|
|
||||||
if (item.status === "success") return 100;
|
|
||||||
if (item.totalPages > 0) {
|
|
||||||
return Math.min(
|
|
||||||
100,
|
|
||||||
Math.round((item.importedPages / item.totalPages) * 100),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return item.status === "processing" ? 5 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProgressCell({ item }: { item: ConfluenceImportHistoryItem }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const value = progressValue(item);
|
|
||||||
const color =
|
|
||||||
item.status === "failed"
|
|
||||||
? "red"
|
|
||||||
: item.status === "success"
|
|
||||||
? "teal"
|
|
||||||
: "blue";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Progress value={value} color={color} size="xs" animated={item.status === "processing"} />
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{item.importedPages}/{item.totalPages || "?"} {t("pages")}
|
|
||||||
</Text>
|
|
||||||
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
· {item.importedSpaces}/{item.totalSpaces || "?"} {t("spaces")}
|
|
||||||
</Text>
|
|
||||||
<Text fz="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
· {item.importedUsers}/{item.totalUsers || "?"} {t("users")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImportStatsModal({
|
|
||||||
item,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
item: ConfluenceImportHistoryItem | null;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const stats = item
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: t("Spaces"),
|
|
||||||
imported: item.importedSpaces,
|
|
||||||
total: item.totalSpaces,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("Pages"),
|
|
||||||
imported: item.importedPages,
|
|
||||||
total: item.totalPages,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("Users"),
|
|
||||||
imported: item.importedUsers,
|
|
||||||
total: item.totalUsers,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("Groups"),
|
|
||||||
imported: item.importedGroups,
|
|
||||||
total: item.totalGroups,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("Attachments"),
|
|
||||||
imported: item.importedAttachments,
|
|
||||||
total: item.totalAttachments,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("Labels"),
|
|
||||||
imported: item.importedLabels,
|
|
||||||
total: item.totalLabels,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("Restricted pages"),
|
|
||||||
imported: item.importedRestrictedPages,
|
|
||||||
total: item.totalRestrictedPages,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={!!item}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t("Import details")}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
{item && (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<div>
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
{t("Confluence site")}
|
|
||||||
</Text>
|
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
|
||||||
{item.siteUrl}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
{t("Started at")}
|
|
||||||
</Text>
|
|
||||||
<Text fz="sm">{formattedDate(new Date(item.createdAt))}</Text>
|
|
||||||
</div>
|
|
||||||
<Table verticalSpacing="xs" fz="sm">
|
|
||||||
<Table.Tbody>
|
|
||||||
{stats.map((stat) => (
|
|
||||||
<Table.Tr key={stat.label}>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm">{stat.label}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" ta="right">
|
|
||||||
{stat.imported} / {stat.total}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TableSkeleton() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<Table.Tr key={i}>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={120} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={180} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={80} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={140} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={120} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Skeleton height={14} width={120} />
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ConfluenceImportHistory() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading } = useConfluenceImportsQuery();
|
|
||||||
const [selectedItem, setSelectedItem] =
|
|
||||||
useState<ConfluenceImportHistoryItem | null>(null);
|
|
||||||
|
|
||||||
const items = useMemo(() => data?.items ?? [], [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table.ScrollContainer minWidth={720}>
|
|
||||||
<Table verticalSpacing="xs" highlightOnHover>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t("Status")}</Table.Th>
|
|
||||||
<Table.Th>{t("Site")}</Table.Th>
|
|
||||||
<Table.Th>{t("Phase")}</Table.Th>
|
|
||||||
<Table.Th>{t("Progress")}</Table.Th>
|
|
||||||
<Table.Th>{t("Started by")}</Table.Th>
|
|
||||||
<Table.Th>{t("Started at")}</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
|
|
||||||
<Table.Tbody>
|
|
||||||
{isLoading ? (
|
|
||||||
<TableSkeleton />
|
|
||||||
) : items.length > 0 ? (
|
|
||||||
items.map((item) => (
|
|
||||||
<Table.Tr
|
|
||||||
key={item.fileTaskId}
|
|
||||||
onClick={() => setSelectedItem(item)}
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
>
|
|
||||||
<Table.Td>
|
|
||||||
{statusBadge(item.status, item.cancelled, t)}
|
|
||||||
{item.status === "failed" && item.errorMessage && (
|
|
||||||
<Tooltip label={item.errorMessage} multiline w={320}>
|
|
||||||
<Text fz="xs" c="red" lineClamp={1} maw={180}>
|
|
||||||
{item.errorMessage}
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" lineClamp={1} maw={240}>
|
|
||||||
{item.siteUrl}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm">{phaseLabel(item.currentPhase, t)}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<ProgressCell item={item} />
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
{item.creatorName ? (
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<CustomAvatar
|
|
||||||
avatarUrl={item.creatorAvatarUrl}
|
|
||||||
name={item.creatorName}
|
|
||||||
size={24}
|
|
||||||
/>
|
|
||||||
<Text fz="sm" lineClamp={1}>
|
|
||||||
{item.creatorName}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
) : (
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
—
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{formattedDate(new Date(item.createdAt))}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<NoTableResults colSpan={6} />
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
<ImportStatsModal
|
|
||||||
item={selectedItem}
|
|
||||||
onClose={() => setSelectedItem(null)}
|
|
||||||
/>
|
|
||||||
</Table.ScrollContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,445 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Group,
|
|
||||||
Modal,
|
|
||||||
PasswordInput,
|
|
||||||
ScrollArea,
|
|
||||||
SegmentedControl,
|
|
||||||
Stack,
|
|
||||||
Stepper,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconAlertCircle,
|
|
||||||
IconCheck,
|
|
||||||
IconCloudCheck,
|
|
||||||
IconPlug,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
listConfluenceSpaces,
|
|
||||||
startConfluenceImport,
|
|
||||||
testConfluenceConnection,
|
|
||||||
} from "@/ee/confluence-import/services/confluence-import-service";
|
|
||||||
import {
|
|
||||||
ConfluenceAuthType,
|
|
||||||
ConfluenceCredentials,
|
|
||||||
ConfluenceSpaceSummary,
|
|
||||||
} from "@/ee/confluence-import/types/confluence-import.types";
|
|
||||||
import { confluenceImportsQueryKey } from "@/ee/confluence-import/queries/confluence-import-queries";
|
|
||||||
|
|
||||||
type ConfluenceEditionChoice = "cloud" | "server";
|
|
||||||
|
|
||||||
type CredentialsFormValues = {
|
|
||||||
edition: ConfluenceEditionChoice;
|
|
||||||
authType: ConfluenceAuthType;
|
|
||||||
siteUrl: string;
|
|
||||||
email: string;
|
|
||||||
token: string;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ConfluenceImportModal({ opened, onClose }: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [active, setActive] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [spaces, setSpaces] = useState<ConfluenceSpaceSummary[]>([]);
|
|
||||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
|
||||||
const [importAll, setImportAll] = useState(true);
|
|
||||||
|
|
||||||
const form = useForm<CredentialsFormValues>({
|
|
||||||
initialValues: {
|
|
||||||
edition: "server",
|
|
||||||
authType: "pat",
|
|
||||||
siteUrl: "",
|
|
||||||
email: "",
|
|
||||||
token: "",
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
},
|
|
||||||
validate: {
|
|
||||||
siteUrl: (value) =>
|
|
||||||
!value?.trim()
|
|
||||||
? t("Site URL is required")
|
|
||||||
: !/^https?:\/\//i.test(value.trim())
|
|
||||||
? t("Site URL must start with http:// or https://")
|
|
||||||
: null,
|
|
||||||
email: (value, values) =>
|
|
||||||
values.edition === "cloud" && !value?.trim()
|
|
||||||
? t("Email is required")
|
|
||||||
: null,
|
|
||||||
token: (value, values) =>
|
|
||||||
(values.authType === "cloud_token" || values.authType === "pat") &&
|
|
||||||
!value?.trim()
|
|
||||||
? t("API token is required")
|
|
||||||
: null,
|
|
||||||
username: (value, values) =>
|
|
||||||
values.authType === "basic" && !value?.trim()
|
|
||||||
? t("Username is required")
|
|
||||||
: null,
|
|
||||||
password: (value, values) =>
|
|
||||||
values.authType === "basic" && !value?.trim()
|
|
||||||
? t("Password is required")
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!opened) {
|
|
||||||
setActive(0);
|
|
||||||
setError(null);
|
|
||||||
setSpaces([]);
|
|
||||||
setSelectedKeys([]);
|
|
||||||
setImportAll(true);
|
|
||||||
setLoading(false);
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [opened]);
|
|
||||||
|
|
||||||
const credentials: ConfluenceCredentials = useMemo(() => {
|
|
||||||
const values = form.values;
|
|
||||||
return {
|
|
||||||
siteUrl: values.siteUrl.trim().replace(/\/+$/, ""),
|
|
||||||
authType: values.authType,
|
|
||||||
email: values.email?.trim() || undefined,
|
|
||||||
token: values.token?.trim() || undefined,
|
|
||||||
username: values.username?.trim() || undefined,
|
|
||||||
password: values.password || undefined,
|
|
||||||
};
|
|
||||||
}, [form.values]);
|
|
||||||
|
|
||||||
const handleEditionChange = (edition: ConfluenceEditionChoice) => {
|
|
||||||
form.setFieldValue("edition", edition);
|
|
||||||
if (edition === "cloud") {
|
|
||||||
form.setFieldValue("authType", "cloud_token");
|
|
||||||
} else if (form.values.authType === "cloud_token") {
|
|
||||||
form.setFieldValue("authType", "pat");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextFromCredentials = async () => {
|
|
||||||
if ((await form.validate()).hasErrors) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const test = await testConfluenceConnection(credentials);
|
|
||||||
if (!test.success) {
|
|
||||||
setError(test.error || t("Connection failed"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const list = await listConfluenceSpaces(credentials);
|
|
||||||
if (!list.success || !list.spaces) {
|
|
||||||
setError(list.error || t("Failed to load spaces"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSpaces(list.spaces);
|
|
||||||
setSelectedKeys(list.spaces.map((s) => s.key));
|
|
||||||
setImportAll(true);
|
|
||||||
setActive(1);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(
|
|
||||||
err?.response?.data?.message || err?.message || t("Unexpected error"),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSpace = (key: string, checked: boolean) => {
|
|
||||||
setSelectedKeys((prev) =>
|
|
||||||
checked
|
|
||||||
? Array.from(new Set([...prev, key]))
|
|
||||||
: prev.filter((k) => k !== key),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAll = (checked: boolean) => {
|
|
||||||
setImportAll(checked);
|
|
||||||
setSelectedKeys(checked ? spaces.map((s) => s.key) : []);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartImport = async () => {
|
|
||||||
const spaceKeys = importAll ? [] : selectedKeys;
|
|
||||||
if (!importAll && spaceKeys.length === 0) {
|
|
||||||
setError(t("Select at least one space to import"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const result = await startConfluenceImport({
|
|
||||||
...credentials,
|
|
||||||
spaceKeys,
|
|
||||||
});
|
|
||||||
if (!result.success || !result.fileTaskId) {
|
|
||||||
setError(result.error || t("Failed to start import"));
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: confluenceImportsQueryKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
notifications.show({
|
|
||||||
title: t("Confluence import started"),
|
|
||||||
message: t("Track progress below. This runs in the background."),
|
|
||||||
color: "blue",
|
|
||||||
icon: <IconCheck size={18} />,
|
|
||||||
autoClose: 4000,
|
|
||||||
});
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(
|
|
||||||
err?.response?.data?.message || err?.message || t("Unexpected error"),
|
|
||||||
);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelFlow = async () => {
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const editionSegment = (
|
|
||||||
<SegmentedControl
|
|
||||||
value={form.values.edition}
|
|
||||||
onChange={(val) => handleEditionChange(val as ConfluenceEditionChoice)}
|
|
||||||
data={[
|
|
||||||
{ value: "server", label: t("Data Center / Server") },
|
|
||||||
{ value: "cloud", label: t("Cloud") },
|
|
||||||
]}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const authTypeSegment = form.values.edition === "server" && (
|
|
||||||
<SegmentedControl
|
|
||||||
value={form.values.authType}
|
|
||||||
onChange={(val) =>
|
|
||||||
form.setFieldValue("authType", val as ConfluenceAuthType)
|
|
||||||
}
|
|
||||||
data={[
|
|
||||||
{ value: "pat", label: t("Personal Access Token") },
|
|
||||||
{ value: "basic", label: t("Username + password") },
|
|
||||||
]}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedCount = importAll ? spaces.length : selectedKeys.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t("Import from Confluence")}
|
|
||||||
size={720}
|
|
||||||
centered
|
|
||||||
closeOnClickOutside={!loading}
|
|
||||||
closeOnEscape={!loading}
|
|
||||||
>
|
|
||||||
<Stepper active={active} size="sm" mb="md" allowNextStepsSelect={false}>
|
|
||||||
<Stepper.Step
|
|
||||||
label={t("Connect")}
|
|
||||||
description={t("Credentials")}
|
|
||||||
icon={<IconPlug size={18} />}
|
|
||||||
/>
|
|
||||||
<Stepper.Step
|
|
||||||
label={t("Select spaces")}
|
|
||||||
description={t("Choose what to import")}
|
|
||||||
icon={<IconCloudCheck size={18} />}
|
|
||||||
/>
|
|
||||||
</Stepper>
|
|
||||||
|
|
||||||
{active === 0 && (
|
|
||||||
<Stack>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t(
|
|
||||||
"Enter your Confluence URL and credentials. We'll validate the connection before continuing.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
{editionSegment}
|
|
||||||
{authTypeSegment}
|
|
||||||
<TextInput
|
|
||||||
label={t("Site URL")}
|
|
||||||
placeholder={
|
|
||||||
form.values.edition === "cloud"
|
|
||||||
? "https://your-site.atlassian.net/wiki"
|
|
||||||
: "https://confluence.example.com"
|
|
||||||
}
|
|
||||||
required
|
|
||||||
{...form.getInputProps("siteUrl")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{form.values.edition === "cloud" && (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
label={t("Email")}
|
|
||||||
placeholder="you@company.com"
|
|
||||||
required
|
|
||||||
{...form.getInputProps("email")}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label={t("API token")}
|
|
||||||
description={t(
|
|
||||||
"Create at id.atlassian.com/manage-profile/security/api-tokens",
|
|
||||||
)}
|
|
||||||
required
|
|
||||||
{...form.getInputProps("token")}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{form.values.edition === "server" &&
|
|
||||||
form.values.authType === "pat" && (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
label={t("Email")}
|
|
||||||
placeholder="you@company.com"
|
|
||||||
{...form.getInputProps("email")}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label={t("Personal Access Token")}
|
|
||||||
required
|
|
||||||
{...form.getInputProps("token")}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{form.values.edition === "server" &&
|
|
||||||
form.values.authType === "basic" && (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
label={t("Username")}
|
|
||||||
required
|
|
||||||
{...form.getInputProps("username")}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label={t("Password")}
|
|
||||||
required
|
|
||||||
{...form.getInputProps("password")}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label={t("Email (optional)")}
|
|
||||||
placeholder="you@company.com"
|
|
||||||
{...form.getInputProps("email")}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert color="red" icon={<IconAlertCircle size={18} />}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={handleCancelFlow}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{t("Cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleNextFromCredentials} loading={loading}>
|
|
||||||
{t("Test & continue")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{active === 1 && (
|
|
||||||
<Stack>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t(
|
|
||||||
"Pages, comments, page labels, users, groups, spaces and permissions will be imported.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
label={t("Import all spaces ({{count}})", {
|
|
||||||
count: spaces.length,
|
|
||||||
})}
|
|
||||||
checked={importAll}
|
|
||||||
onChange={(e) => toggleAll(e.currentTarget.checked)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ScrollArea h={320} type="auto" offsetScrollbars>
|
|
||||||
<Stack gap="xs">
|
|
||||||
{spaces.map((space) => (
|
|
||||||
<Checkbox
|
|
||||||
key={space.id}
|
|
||||||
label={
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
<Text fw={500}>{space.name}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
({space.key})
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
}
|
|
||||||
checked={importAll || selectedKeys.includes(space.key)}
|
|
||||||
disabled={importAll}
|
|
||||||
onChange={(e) =>
|
|
||||||
toggleSpace(space.key, e.currentTarget.checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{spaces.length === 0 && (
|
|
||||||
<Text c="dimmed" ta="center" py="lg">
|
|
||||||
{t("No spaces found for this account.")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert color="red" icon={<IconAlertCircle size={18} />}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("{{count}} selected", { count: selectedCount })}
|
|
||||||
</Text>
|
|
||||||
<Group>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={() => setActive(0)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{t("Back")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleStartImport}
|
|
||||||
loading={loading}
|
|
||||||
disabled={!importAll && selectedKeys.length === 0}
|
|
||||||
>
|
|
||||||
{t("Start import")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { Helmet } from "react-helmet-async";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Divider,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
import SettingsTitle from "@/components/settings/settings-title";
|
|
||||||
import { ConfluenceIcon } from "@/components/icons/confluence-icon";
|
|
||||||
import ConfluenceImportModal from "@/ee/confluence-import/components/confluence-import-modal";
|
|
||||||
import ConfluenceImportHistory from "@/ee/confluence-import/components/confluence-import-history";
|
|
||||||
import { getAppName } from "@/lib/config";
|
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
|
||||||
import { Feature } from "@/ee/features";
|
|
||||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
|
||||||
|
|
||||||
export default function ConfluenceImportPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
|
||||||
const hasConfluenceImport = useHasFeature(Feature.CONFLUENCE_API_IMPORT);
|
|
||||||
const upgradeLabel = useUpgradeLabel();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Helmet>
|
|
||||||
<title>
|
|
||||||
{t("Import from Confluence")} - {getAppName()}
|
|
||||||
</title>
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
<SettingsTitle title={t("Import from Confluence")} />
|
|
||||||
|
|
||||||
<Paper withBorder p="lg" radius="md" mb="lg">
|
|
||||||
<Group align="flex-start" justify="space-between" wrap="nowrap">
|
|
||||||
<Group align="flex-start" wrap="nowrap">
|
|
||||||
<ConfluenceIcon size={32} />
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Text fw={600}>{t("Confluence API import")}</Text>
|
|
||||||
<Text size="sm" c="dimmed" maw={560}>
|
|
||||||
{t(
|
|
||||||
"Connect to Confluence Cloud or Data Center to import spaces, pages, attachments, comments, users, groups and permissions directly via the API.",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Tooltip label={upgradeLabel} disabled={hasConfluenceImport}>
|
|
||||||
<Button onClick={open} disabled={!hasConfluenceImport}>
|
|
||||||
{t("Start import")}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Divider my="md" label={t("Import history")} labelPosition="left" />
|
|
||||||
|
|
||||||
<ConfluenceImportHistory />
|
|
||||||
|
|
||||||
<ConfluenceImportModal opened={opened} onClose={close} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { listConfluenceImports } from "@/ee/confluence-import/services/confluence-import-service";
|
|
||||||
|
|
||||||
export const confluenceImportsQueryKey = ["confluence-imports"] as const;
|
|
||||||
|
|
||||||
export function useConfluenceImportsQuery() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: confluenceImportsQueryKey,
|
|
||||||
queryFn: listConfluenceImports,
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
const hasRunning = query.state.data?.items?.some(
|
|
||||||
(i) => i.status === "processing",
|
|
||||||
);
|
|
||||||
return hasRunning ? 3000 : false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import {
|
|
||||||
ConfluenceCredentials,
|
|
||||||
ImportStatusResponse,
|
|
||||||
ListImportsResponse,
|
|
||||||
ListSpacesResponse,
|
|
||||||
StartImportResponse,
|
|
||||||
TestConnectionResponse,
|
|
||||||
} from "@/ee/confluence-import/types/confluence-import.types";
|
|
||||||
|
|
||||||
export async function testConfluenceConnection(
|
|
||||||
data: ConfluenceCredentials,
|
|
||||||
): Promise<TestConnectionResponse> {
|
|
||||||
const req = await api.post<TestConnectionResponse>(
|
|
||||||
"/confluence-import/test-connection",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listConfluenceSpaces(
|
|
||||||
data: ConfluenceCredentials,
|
|
||||||
): Promise<ListSpacesResponse> {
|
|
||||||
const req = await api.post<ListSpacesResponse>(
|
|
||||||
"/confluence-import/spaces",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startConfluenceImport(
|
|
||||||
data: ConfluenceCredentials & { spaceKeys?: string[] },
|
|
||||||
): Promise<StartImportResponse> {
|
|
||||||
const req = await api.post<StartImportResponse>(
|
|
||||||
"/confluence-import/start",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getConfluenceImportStatus(
|
|
||||||
fileTaskId: string,
|
|
||||||
): Promise<ImportStatusResponse> {
|
|
||||||
const req = await api.post<ImportStatusResponse>(
|
|
||||||
"/confluence-import/status",
|
|
||||||
{ fileTaskId },
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listConfluenceImports(): Promise<ListImportsResponse> {
|
|
||||||
const req = await api.post<ListImportsResponse>("/confluence-import/history");
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelConfluenceImport(
|
|
||||||
fileTaskId: string,
|
|
||||||
): Promise<{ success: boolean }> {
|
|
||||||
const req = await api.post<{ success: boolean }>(
|
|
||||||
"/confluence-import/cancel",
|
|
||||||
{ fileTaskId },
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
export type ConfluenceAuthType = "cloud_token" | "pat" | "basic";
|
|
||||||
|
|
||||||
export type ConfluenceCredentials = {
|
|
||||||
siteUrl: string;
|
|
||||||
authType: ConfluenceAuthType;
|
|
||||||
email?: string;
|
|
||||||
token?: string;
|
|
||||||
username?: string;
|
|
||||||
password?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConfluenceSpaceSummary = {
|
|
||||||
id: string;
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
type?: string;
|
|
||||||
status?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TestConnectionResponse = {
|
|
||||||
success: boolean;
|
|
||||||
edition?: string;
|
|
||||||
spaceCount?: number;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListSpacesResponse = {
|
|
||||||
success: boolean;
|
|
||||||
spaces?: ConfluenceSpaceSummary[];
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StartImportResponse = {
|
|
||||||
success: boolean;
|
|
||||||
fileTaskId?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConfluenceImportStatus = "processing" | "success" | "failed";
|
|
||||||
|
|
||||||
export type ImportStatusResponse = {
|
|
||||||
fileTaskId?: string;
|
|
||||||
status?: ConfluenceImportStatus;
|
|
||||||
errorMessage?: string | null;
|
|
||||||
currentPhase?: string | null;
|
|
||||||
totalSpaces?: number;
|
|
||||||
importedSpaces?: number;
|
|
||||||
totalPages?: number;
|
|
||||||
importedPages?: number;
|
|
||||||
totalUsers?: number;
|
|
||||||
importedUsers?: number;
|
|
||||||
totalGroups?: number;
|
|
||||||
importedGroups?: number;
|
|
||||||
totalRestrictedPages?: number;
|
|
||||||
importedRestrictedPages?: number;
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConfluenceImportHistoryItem = {
|
|
||||||
fileTaskId: string;
|
|
||||||
siteUrl: string;
|
|
||||||
status: ConfluenceImportStatus;
|
|
||||||
errorMessage: string | null;
|
|
||||||
currentPhase: string | null;
|
|
||||||
totalSpaces: number;
|
|
||||||
importedSpaces: number;
|
|
||||||
totalPages: number;
|
|
||||||
importedPages: number;
|
|
||||||
totalUsers: number;
|
|
||||||
importedUsers: number;
|
|
||||||
totalGroups: number;
|
|
||||||
importedGroups: number;
|
|
||||||
totalAttachments: number;
|
|
||||||
importedAttachments: number;
|
|
||||||
totalLabels: number;
|
|
||||||
importedLabels: number;
|
|
||||||
totalRestrictedPages: number;
|
|
||||||
importedRestrictedPages: number;
|
|
||||||
cancelled: boolean;
|
|
||||||
spaceKeys: string[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
creatorId: string | null;
|
|
||||||
creatorName: string | null;
|
|
||||||
creatorAvatarUrl: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListImportsResponse = {
|
|
||||||
items: ConfluenceImportHistoryItem[];
|
|
||||||
};
|
|
||||||
@@ -7,7 +7,6 @@ export const Feature = {
|
|||||||
PAGE_PERMISSIONS: 'page:permissions',
|
PAGE_PERMISSIONS: 'page:permissions',
|
||||||
AI: 'ai',
|
AI: 'ai',
|
||||||
CONFLUENCE_IMPORT: 'import:confluence',
|
CONFLUENCE_IMPORT: 'import:confluence',
|
||||||
CONFLUENCE_API_IMPORT: 'import:confluence-api',
|
|
||||||
DOCX_IMPORT: 'import:docx',
|
DOCX_IMPORT: 'import:docx',
|
||||||
PDF_IMPORT: 'import:pdf',
|
PDF_IMPORT: 'import:pdf',
|
||||||
ATTACHMENT_INDEXING: 'attachment:indexing',
|
ATTACHMENT_INDEXING: 'attachment:indexing',
|
||||||
|
|||||||
@@ -53,14 +53,7 @@ export function PagePermissionItem({
|
|||||||
{isCurrentUser && <Text span c="dimmed"> ({t("You")})</Text>}
|
{isCurrentUser && <Text span c="dimmed"> ({t("You")})</Text>}
|
||||||
</AutoTooltipText>
|
</AutoTooltipText>
|
||||||
<AutoTooltipText fz="xs" c="dimmed">
|
<AutoTooltipText fz="xs" c="dimmed">
|
||||||
{member.type === "user"
|
{member.type === "user" ? member.email : formatMemberCount(member.memberCount, t)}
|
||||||
? 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>
|
</AutoTooltipText>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -166,18 +166,6 @@ export default function useAuth() {
|
|||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setCurrentUser(RESET);
|
setCurrentUser(RESET);
|
||||||
await logout();
|
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`);
|
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
} from "@/features/comment/atoms/comment-atom";
|
} from "@/features/comment/atoms/comment-atom";
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
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 { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import { useCreateCommentMutation } from "@/features/comment/queries/comment-query";
|
import { useCreateCommentMutation } from "@/features/comment/queries/comment-query";
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||||
@@ -171,16 +170,14 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<CommentErrorBoundary>
|
<CommentEditor
|
||||||
<CommentEditor
|
onUpdate={handleCommentEditorChange}
|
||||||
onUpdate={handleCommentEditorChange}
|
onSave={handleAddComment}
|
||||||
onSave={handleAddComment}
|
placeholder={t("Write a comment")}
|
||||||
placeholder={t("Write a comment")}
|
editable={true}
|
||||||
editable={true}
|
autofocus={true}
|
||||||
autofocus={true}
|
/>
|
||||||
/>
|
<CommentActions onSave={handleAddComment} isLoading={isPending} />
|
||||||
<CommentActions onSave={handleAddComment} isLoading={isPending} />
|
|
||||||
</CommentErrorBoundary>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||||
import { StarterKit } from "@tiptap/starter-kit";
|
import { StarterKit } from "@tiptap/starter-kit";
|
||||||
import { TextStyle } from "@tiptap/extension-text-style";
|
import { Mention, LinkExtension } from "@docmost/editor-ext";
|
||||||
import { Mention, LinkExtension, Color } from "@docmost/editor-ext";
|
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useFocusWithin } from "@mantine/hooks";
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -12,8 +11,6 @@ import EmojiCommand from "@/features/editor/extensions/emoji-command";
|
|||||||
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
|
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
|
||||||
import MentionView from "@/features/editor/components/mention/mention-view";
|
import MentionView from "@/features/editor/components/mention/mention-view";
|
||||||
import { platformModifierKey } from "@/lib";
|
import { platformModifierKey } from "@/lib";
|
||||||
import { TableKit } from "@tiptap/extension-table";
|
|
||||||
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
|
||||||
|
|
||||||
interface CommentEditorProps {
|
interface CommentEditorProps {
|
||||||
defaultContent?: any;
|
defaultContent?: any;
|
||||||
@@ -52,8 +49,6 @@ const CommentEditor = forwardRef(
|
|||||||
placeholder: placeholder || t("Reply..."),
|
placeholder: placeholder || t("Reply..."),
|
||||||
}),
|
}),
|
||||||
LinkExtension,
|
LinkExtension,
|
||||||
TextStyle,
|
|
||||||
Color,
|
|
||||||
EmojiCommand,
|
EmojiCommand,
|
||||||
Mention.configure({
|
Mention.configure({
|
||||||
suggestion: {
|
suggestion: {
|
||||||
@@ -71,11 +66,6 @@ const CommentEditor = forwardRef(
|
|||||||
return ReactNodeViewRenderer(MentionView);
|
return ReactNodeViewRenderer(MentionView);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
TableKit,
|
|
||||||
TaskList,
|
|
||||||
TaskItem.configure({
|
|
||||||
nested: true,
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -123,12 +113,7 @@ const CommentEditor = forwardRef(
|
|||||||
// websocket on another browser). Skip for editable editors to avoid
|
// websocket on another browser). Skip for editable editors to avoid
|
||||||
// resetting the cursor position on every keystroke.
|
// resetting the cursor position on every keystroke.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (!editable && commentEditor && !commentEditor.isDestroyed && defaultContent) {
|
||||||
!editable &&
|
|
||||||
commentEditor &&
|
|
||||||
!commentEditor.isDestroyed &&
|
|
||||||
defaultContent
|
|
||||||
) {
|
|
||||||
commentEditor.commands.setContent(defaultContent);
|
commentEditor.commands.setContent(defaultContent);
|
||||||
}
|
}
|
||||||
}, [defaultContent, editable, commentEditor]);
|
}, [defaultContent, editable, commentEditor]);
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { ReactNode } from "react";
|
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
|
||||||
import { Button } from "@mantine/core";
|
|
||||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
|
||||||
|
|
||||||
type CommentErrorBoundaryProps = {
|
|
||||||
children: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Contain comment-editor render throws (e.g. schema errors) so they don't unmount the whole app.
|
|
||||||
export function CommentErrorBoundary({ children }: CommentErrorBoundaryProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ErrorBoundary
|
|
||||||
fallbackRender={({ resetErrorBoundary }) => (
|
|
||||||
<EmptyState
|
|
||||||
icon={IconAlertTriangle}
|
|
||||||
title={t("Failed to load comments. An error occurred.")}
|
|
||||||
action={
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
mt="xs"
|
|
||||||
onClick={resetErrorBoundary}
|
|
||||||
>
|
|
||||||
{t("Try again")}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -53,10 +53,6 @@
|
|||||||
margin-block-end: 0;
|
margin-block-end: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror :global(.tableWrapper) table {
|
|
||||||
min-width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,16 +247,6 @@ export default function LinkView(props: MarkViewProps) {
|
|||||||
const handleNavigate = useCallback(() => {
|
const handleNavigate = useCallback(() => {
|
||||||
if (!href) return;
|
if (!href) return;
|
||||||
|
|
||||||
if (href.startsWith("#")) {
|
|
||||||
const anchor = href.slice(1);
|
|
||||||
const element =
|
|
||||||
document.querySelector(`[id="${anchor}"]`) ||
|
|
||||||
document.querySelector(`[data-id="${anchor}"]`);
|
|
||||||
element?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
navigate(`${location.pathname}#${anchor}`, { replace: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isInternal) {
|
if (isInternal) {
|
||||||
let targetPath = href;
|
let targetPath = href;
|
||||||
let anchor = "";
|
let anchor = "";
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
const { node } = props;
|
const { node } = props;
|
||||||
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
||||||
const isPageMention = entityType === "page";
|
const isPageMention = entityType === "page";
|
||||||
const hasTarget = isPageMention && !!slugId;
|
|
||||||
const { spaceSlug, pageSlug } = useParams();
|
const { spaceSlug, pageSlug } = useParams();
|
||||||
const { shareId } = useParams();
|
const { shareId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -79,22 +78,7 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPageMention && !hasTarget && (
|
{isPageMention && isShareRoute && (
|
||||||
<Text component="span" fw={500} className={classes.pageMentionLink}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="transparent"
|
|
||||||
color="gray"
|
|
||||||
component="span"
|
|
||||||
size={18}
|
|
||||||
style={{ verticalAlign: "text-bottom" }}
|
|
||||||
>
|
|
||||||
<IconFileDescription size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
<span className={classes.pageMentionText}>{label}</span>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasTarget && isShareRoute && (
|
|
||||||
<Anchor
|
<Anchor
|
||||||
component={Link}
|
component={Link}
|
||||||
fw={500}
|
fw={500}
|
||||||
@@ -118,7 +102,7 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasTarget && isPublicSpaceRoute && publicPageData?.page && (
|
{isPageMention && isPublicSpaceRoute && publicPageData?.page && (
|
||||||
<Anchor
|
<Anchor
|
||||||
component={Link}
|
component={Link}
|
||||||
fw={500}
|
fw={500}
|
||||||
@@ -150,7 +134,7 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
{/* No public URL: the /p/ resolver redirects members to the page and
|
{/* No public URL: the /p/ resolver redirects members to the page and
|
||||||
funnels anonymous visitors through login first. New tab, so the
|
funnels anonymous visitors through login first. New tab, so the
|
||||||
redirect chain never rewrites the docs tab's history. */}
|
redirect chain never rewrites the docs tab's history. */}
|
||||||
{hasTarget && isPublicSpaceRoute && !publicPageData?.page && (
|
{isPageMention && isPublicSpaceRoute && !publicPageData?.page && (
|
||||||
<Anchor
|
<Anchor
|
||||||
fw={500}
|
fw={500}
|
||||||
href={buildPageUrl(undefined, slugId, label, anchorId)}
|
href={buildPageUrl(undefined, slugId, label, anchorId)}
|
||||||
@@ -172,7 +156,7 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasTarget && !isShareRoute && !isPublicSpaceRoute && isError && (
|
{isPageMention && !isShareRoute && !isPublicSpaceRoute && isError && (
|
||||||
<Anchor
|
<Anchor
|
||||||
component={Link}
|
component={Link}
|
||||||
fw={500}
|
fw={500}
|
||||||
@@ -196,7 +180,7 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasTarget && !isShareRoute && !isPublicSpaceRoute && !isError && (
|
{isPageMention && !isShareRoute && !isPublicSpaceRoute && !isError && (
|
||||||
<Anchor
|
<Anchor
|
||||||
component={Link}
|
component={Link}
|
||||||
fw={500}
|
fw={500}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Superscript } from "@tiptap/extension-superscript";
|
|||||||
import SubScript from "@tiptap/extension-subscript";
|
import SubScript from "@tiptap/extension-subscript";
|
||||||
import { Typography } from "@tiptap/extension-typography";
|
import { Typography } from "@tiptap/extension-typography";
|
||||||
import { TextStyle } from "@tiptap/extension-text-style";
|
import { TextStyle } from "@tiptap/extension-text-style";
|
||||||
|
import { Color } from "@tiptap/extension-color";
|
||||||
import { Youtube } from "@tiptap/extension-youtube";
|
import { Youtube } from "@tiptap/extension-youtube";
|
||||||
import SlashCommand, {
|
import SlashCommand, {
|
||||||
SlashCommandExtension as Command,
|
SlashCommandExtension as Command,
|
||||||
@@ -53,7 +54,6 @@ import {
|
|||||||
Subpages,
|
Subpages,
|
||||||
Heading,
|
Heading,
|
||||||
Highlight,
|
Highlight,
|
||||||
Color,
|
|
||||||
Indent,
|
Indent,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
SharedStorage,
|
SharedStorage,
|
||||||
|
|||||||
@@ -1,46 +1,6 @@
|
|||||||
/* Highlight colors with dark mode support */
|
/* Highlight colors with dark mode support */
|
||||||
|
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
@mixin dark {
|
|
||||||
/* Arbitrary (imported) colors have no hand-tuned dark variant, so derive
|
|
||||||
one: cap lightness and chroma so the fill sits on the dark surface.
|
|
||||||
Our own palette opts out and keeps the values below. */
|
|
||||||
mark[data-color]:not(
|
|
||||||
[data-color="#98d8f2" i],
|
|
||||||
[data-color="#7edb6c" i],
|
|
||||||
[data-color="#e0d6ed" i],
|
|
||||||
[data-color="#ffc6c2" i],
|
|
||||||
[data-color="#faf594" i],
|
|
||||||
[data-color="#f5c8a9" i],
|
|
||||||
[data-color="#f5cfe0" i],
|
|
||||||
[data-color="#dfdfd7" i],
|
|
||||||
[data-color="#d7c4b7" i]
|
|
||||||
) {
|
|
||||||
background-color: oklch(
|
|
||||||
from var(--mark-bg, #fff) min(l, clamp(0.28, calc(1.22 - l), 0.45))
|
|
||||||
min(calc(c * 1.8), 0.09) h
|
|
||||||
) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Imported text colors are picked for a light page and go unreadable on
|
|
||||||
the dark surface, so lift their lightness. */
|
|
||||||
span[data-text-color]:not(
|
|
||||||
[data-text-color="#2563EB" i],
|
|
||||||
[data-text-color="#008A00" i],
|
|
||||||
[data-text-color="#9333EA" i],
|
|
||||||
[data-text-color="#E00000" i],
|
|
||||||
[data-text-color="#EAB308" i],
|
|
||||||
[data-text-color="#FFA500" i],
|
|
||||||
[data-text-color="#BA4081" i],
|
|
||||||
[data-text-color="#A8A29E" i],
|
|
||||||
[data-text-color="#92400E" i]
|
|
||||||
) {
|
|
||||||
color: oklch(
|
|
||||||
from var(--text-color, currentcolor) max(l, 0.72) min(c, 0.16) h
|
|
||||||
) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Blue */
|
/* Blue */
|
||||||
mark[data-color="#98d8f2"] {
|
mark[data-color="#98d8f2"] {
|
||||||
background-color: light-dark(
|
background-color: light-dark(
|
||||||
|
|||||||
@@ -86,16 +86,6 @@
|
|||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
table {
|
table {
|
||||||
@mixin dark {
|
@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 */
|
/* Blue */
|
||||||
td[data-background-color="#b4d5ff"],
|
td[data-background-color="#b4d5ff"],
|
||||||
th[data-background-color="#b4d5ff"] {
|
th[data-background-color="#b4d5ff"] {
|
||||||
|
|||||||
@@ -15,8 +15,6 @@
|
|||||||
--docs-accent: #2b7af1;
|
--docs-accent: #2b7af1;
|
||||||
--docs-accent-soft: color-mix(in srgb, var(--docs-accent) 10%, transparent);
|
--docs-accent-soft: color-mix(in srgb, var(--docs-accent) 10%, transparent);
|
||||||
|
|
||||||
/* Cloudflare-style single-ink model: one foreground for headings, bold, and
|
|
||||||
* body on a just-off-white page; neither end of the scale is pure. */
|
|
||||||
--docs-bg: oklch(99% 0 0);
|
--docs-bg: oklch(99% 0 0);
|
||||||
--docs-fg: oklch(21% 0 0);
|
--docs-fg: oklch(21% 0 0);
|
||||||
--docs-content-fg: var(--docs-fg);
|
--docs-content-fg: var(--docs-fg);
|
||||||
@@ -410,7 +408,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Expanded parents read as section headers, Cloudflare-style. */
|
/* Expanded parents read as section headers. */
|
||||||
.treeRow[data-open-parent="true"] {
|
.treeRow[data-open-parent="true"] {
|
||||||
color: var(--docs-fg);
|
color: var(--docs-fg);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -543,8 +541,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Sidebar branding experiments (GitBook card / ReadMe line) ---------- */
|
|
||||||
|
|
||||||
/* ---------- Footer branding ---------- */
|
/* ---------- Footer branding ---------- */
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
@@ -773,13 +769,11 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modest semibold heading scale (Cloudflare-style); class doubled to outrank
|
|
||||||
* the shared editor and .public-typography rules. */
|
|
||||||
.root.root :global(.ProseMirror) h1 {
|
.root.root :global(.ProseMirror) h1 {
|
||||||
font-size: 2.1875rem;
|
font-size: 1.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: -0.025em;
|
letter-spacing: -0.02em;
|
||||||
line-height: 1.25;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root.root :global(.ProseMirror) h2 {
|
.root.root :global(.ProseMirror) h2 {
|
||||||
|
|||||||
@@ -51,10 +51,6 @@ export function getAiVectorDriver(): string {
|
|||||||
return getConfigValue("AI_VECTOR_DRIVER");
|
return getConfigValue("AI_VECTOR_DRIVER");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isBetaConfluenceImporter(): boolean {
|
|
||||||
return castToBoolean(getConfigValue("BETA_CONFLUENCE_IMPORTER"));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAvatarUrl(
|
export function getAvatarUrl(
|
||||||
avatarUrl: string,
|
avatarUrl: string,
|
||||||
type: AvatarIconType = AvatarIconType.AVATAR,
|
type: AvatarIconType = AvatarIconType.AVATAR,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export default defineConfig(({ mode }) => {
|
|||||||
POSTHOG_HOST,
|
POSTHOG_HOST,
|
||||||
POSTHOG_KEY,
|
POSTHOG_KEY,
|
||||||
AI_VECTOR_DRIVER,
|
AI_VECTOR_DRIVER,
|
||||||
BETA_CONFLUENCE_IMPORTER,
|
|
||||||
BETA_PUBLIC_SPACES,
|
BETA_PUBLIC_SPACES,
|
||||||
} = loadEnv(mode, envPath, "");
|
} = loadEnv(mode, envPath, "");
|
||||||
|
|
||||||
@@ -35,7 +34,6 @@ export default defineConfig(({ mode }) => {
|
|||||||
POSTHOG_HOST,
|
POSTHOG_HOST,
|
||||||
POSTHOG_KEY,
|
POSTHOG_KEY,
|
||||||
AI_VECTOR_DRIVER,
|
AI_VECTOR_DRIVER,
|
||||||
BETA_CONFLUENCE_IMPORTER,
|
|
||||||
BETA_PUBLIC_SPACES,
|
BETA_PUBLIC_SPACES,
|
||||||
},
|
},
|
||||||
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
||||||
|
|||||||
@@ -79,7 +79,6 @@
|
|||||||
"class-validator": "0.15.1",
|
"class-validator": "0.15.1",
|
||||||
"cookie": "1.1.1",
|
"cookie": "1.1.1",
|
||||||
"csv-stringify": "6.8.0",
|
"csv-stringify": "6.8.0",
|
||||||
"entities": "7.0.1",
|
|
||||||
"fast-bm25": "0.0.5",
|
"fast-bm25": "0.0.5",
|
||||||
"fastify-ip": "2.0.0",
|
"fastify-ip": "2.0.0",
|
||||||
"fs-extra": "11.3.4",
|
"fs-extra": "11.3.4",
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
|||||||
import { HealthModule } from './integrations/health/health.module';
|
import { HealthModule } from './integrations/health/health.module';
|
||||||
import { ExportModule } from './integrations/export/export.module';
|
import { ExportModule } from './integrations/export/export.module';
|
||||||
import { ImportModule } from './integrations/import/import.module';
|
import { ImportModule } from './integrations/import/import.module';
|
||||||
import { ImportProcessorModule } from './integrations/import/import-processor.module';
|
|
||||||
import { SecurityModule } from './integrations/security/security.module';
|
import { SecurityModule } from './integrations/security/security.module';
|
||||||
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
|
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
|
||||||
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
||||||
@@ -89,7 +88,6 @@ try {
|
|||||||
StaticModule,
|
StaticModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
ImportProcessorModule,
|
|
||||||
ExportModule,
|
ExportModule,
|
||||||
StorageModule.forRootAsync({
|
StorageModule.forRootAsync({
|
||||||
imports: [EnvironmentModule],
|
imports: [EnvironmentModule],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Superscript } from '@tiptap/extension-superscript';
|
|||||||
import SubScript from '@tiptap/extension-subscript';
|
import SubScript from '@tiptap/extension-subscript';
|
||||||
import { Typography } from '@tiptap/extension-typography';
|
import { Typography } from '@tiptap/extension-typography';
|
||||||
import { TextStyle } from '@tiptap/extension-text-style';
|
import { TextStyle } from '@tiptap/extension-text-style';
|
||||||
|
import { Color } from '@tiptap/extension-color';
|
||||||
import { Youtube } from '@tiptap/extension-youtube';
|
import { Youtube } from '@tiptap/extension-youtube';
|
||||||
import { TaskList, TaskItem } from '@tiptap/extension-list';
|
import { TaskList, TaskItem } from '@tiptap/extension-list';
|
||||||
import {
|
import {
|
||||||
@@ -35,7 +36,6 @@ import {
|
|||||||
Mention,
|
Mention,
|
||||||
Subpages,
|
Subpages,
|
||||||
Highlight,
|
Highlight,
|
||||||
Color,
|
|
||||||
Indent,
|
Indent,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
Columns,
|
Columns,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export const Feature = {
|
|||||||
PAGE_PERMISSIONS: 'page:permissions',
|
PAGE_PERMISSIONS: 'page:permissions',
|
||||||
AI: 'ai',
|
AI: 'ai',
|
||||||
CONFLUENCE_IMPORT: 'import:confluence',
|
CONFLUENCE_IMPORT: 'import:confluence',
|
||||||
CONFLUENCE_API_IMPORT: 'import:confluence-api',
|
|
||||||
DOCX_IMPORT: 'import:docx',
|
DOCX_IMPORT: 'import:docx',
|
||||||
PDF_IMPORT: 'import:pdf',
|
PDF_IMPORT: 'import:pdf',
|
||||||
ATTACHMENT_INDEXING: 'attachment:indexing',
|
ATTACHMENT_INDEXING: 'attachment:indexing',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -20,7 +21,7 @@ import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
|||||||
import { MovePageDto } from '../dto/move-page.dto';
|
import { MovePageDto } from '../dto/move-page.dto';
|
||||||
import { generateSlugId } from '../../../common/helpers';
|
import { generateSlugId } from '../../../common/helpers';
|
||||||
import { getPageTitle } from '../../../common/helpers';
|
import { getPageTitle } from '../../../common/helpers';
|
||||||
import { executeTx } from '@docmost/db/utils';
|
import { dbOrTx, executeTx } from '@docmost/db/utils';
|
||||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||||
import { v7 as uuid7 } from 'uuid';
|
import { v7 as uuid7 } from 'uuid';
|
||||||
import {
|
import {
|
||||||
@@ -174,8 +175,49 @@ export class PageService {
|
|||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
async nextPagePosition(spaceId: string, parentPageId?: string) {
|
async nextPagePosition(
|
||||||
return this.pageRepo.nextPagePosition(spaceId, parentPageId);
|
spaceId: string,
|
||||||
|
parentPageId?: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
) {
|
||||||
|
let pagePosition: string;
|
||||||
|
|
||||||
|
const lastPageQuery = dbOrTx(this.db, trx)
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['position'])
|
||||||
|
.where('spaceId', '=', spaceId)
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
|
.orderBy('position', (ob) => ob.collate('C').desc())
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (parentPageId) {
|
||||||
|
// check for children of this page
|
||||||
|
const lastPage = await lastPageQuery
|
||||||
|
.where('parentPageId', '=', parentPageId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!lastPage) {
|
||||||
|
pagePosition = generateJitteredKeyBetween(null, null);
|
||||||
|
} else {
|
||||||
|
// if there is an existing page, we should get a position below it
|
||||||
|
pagePosition = generateJitteredKeyBetween(lastPage.position, null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// for root page
|
||||||
|
const lastPage = await lastPageQuery
|
||||||
|
.where('parentPageId', 'is', null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
// if no existing page, make this the first
|
||||||
|
if (!lastPage) {
|
||||||
|
pagePosition = generateJitteredKeyBetween(null, null); // we expect "a0"
|
||||||
|
} else {
|
||||||
|
// if there is an existing page, we should get a position below it
|
||||||
|
pagePosition = generateJitteredKeyBetween(lastPage.position, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pagePosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
@@ -354,35 +396,46 @@ export class PageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
|
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
|
||||||
let childPageIds: string[] = [];
|
return executeTx(this.db, async (trx) => {
|
||||||
|
await this.pageRepo.lockPageHierarchySpaces(
|
||||||
|
[rootPage.spaceId, spaceId],
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
|
||||||
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
const currentRootPage = await this.pageRepo.findById(rootPage.id, {
|
||||||
includeContent: false,
|
trx,
|
||||||
});
|
});
|
||||||
|
if (!currentRootPage || currentRootPage.deletedAt) {
|
||||||
|
throw new NotFoundException('Page to move not found');
|
||||||
|
}
|
||||||
|
if (currentRootPage.spaceId !== rootPage.spaceId) {
|
||||||
|
throw new ConflictException('Page location changed; retry the move');
|
||||||
|
}
|
||||||
|
|
||||||
// Filter to only accessible pages while maintaining tree integrity
|
const allPages = await this.pageRepo.getPageAndDescendants(
|
||||||
const accessiblePages = await this.filterAccessibleTreePages(
|
currentRootPage.id,
|
||||||
allPages,
|
{ includeContent: false, trx },
|
||||||
rootPage.id,
|
);
|
||||||
userId,
|
const accessiblePages = await this.filterAccessibleTreePages(
|
||||||
rootPage.spaceId,
|
allPages,
|
||||||
);
|
currentRootPage.id,
|
||||||
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
|
userId,
|
||||||
|
currentRootPage.spaceId,
|
||||||
|
);
|
||||||
|
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
|
||||||
|
const pagesToOrphan = allPages.filter(
|
||||||
|
(p) =>
|
||||||
|
!accessibleIds.has(p.id) &&
|
||||||
|
p.parentPageId &&
|
||||||
|
accessibleIds.has(p.parentPageId),
|
||||||
|
);
|
||||||
|
|
||||||
// Find inaccessible pages whose parent is being moved - these need to be orphaned
|
|
||||||
const pagesToOrphan = allPages.filter(
|
|
||||||
(p) =>
|
|
||||||
!accessibleIds.has(p.id) &&
|
|
||||||
p.parentPageId &&
|
|
||||||
accessibleIds.has(p.parentPageId),
|
|
||||||
);
|
|
||||||
|
|
||||||
await executeTx(this.db, async (trx) => {
|
|
||||||
// Orphan inaccessible child pages (make them root pages in original space)
|
// Orphan inaccessible child pages (make them root pages in original space)
|
||||||
for (const page of pagesToOrphan) {
|
for (const page of pagesToOrphan) {
|
||||||
const orphanPosition = await this.nextPagePosition(
|
const orphanPosition = await this.nextPagePosition(
|
||||||
rootPage.spaceId,
|
currentRootPage.spaceId,
|
||||||
null,
|
null,
|
||||||
|
trx,
|
||||||
);
|
);
|
||||||
await this.pageRepo.updatePage(
|
await this.pageRepo.updatePage(
|
||||||
{ parentPageId: null, position: orphanPosition },
|
{ parentPageId: null, position: orphanPosition },
|
||||||
@@ -392,16 +445,18 @@ export class PageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update root page
|
// Update root page
|
||||||
const nextPosition = await this.nextPagePosition(spaceId);
|
const nextPosition = await this.nextPagePosition(spaceId, null, trx);
|
||||||
await this.pageRepo.updatePage(
|
await this.pageRepo.updatePage(
|
||||||
{ spaceId, parentPageId: null, position: nextPosition },
|
{ spaceId, parentPageId: null, position: nextPosition },
|
||||||
rootPage.id,
|
currentRootPage.id,
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
|
|
||||||
const pageIdsToMove = accessiblePages.map((p) => p.id);
|
const pageIdsToMove = accessiblePages.map((p) => p.id);
|
||||||
|
|
||||||
childPageIds = pageIdsToMove.filter((id) => id !== rootPage.id);
|
const childPageIds = pageIdsToMove.filter(
|
||||||
|
(id) => id !== currentRootPage.id,
|
||||||
|
);
|
||||||
|
|
||||||
if (pageIdsToMove.length > 1) {
|
if (pageIdsToMove.length > 1) {
|
||||||
// Update sub pages (all accessible pages except root)
|
// Update sub pages (all accessible pages except root)
|
||||||
@@ -464,7 +519,7 @@ export class PageService {
|
|||||||
{
|
{
|
||||||
pageIds: pageIdsToMove,
|
pageIds: pageIdsToMove,
|
||||||
spaceId,
|
spaceId,
|
||||||
workspaceId: rootPage.workspaceId,
|
workspaceId: currentRootPage.workspaceId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attempts: 2,
|
attempts: 2,
|
||||||
@@ -475,9 +530,9 @@ export class PageService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return { childPageIds };
|
return { childPageIds };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async duplicatePage(
|
async duplicatePage(
|
||||||
@@ -788,31 +843,59 @@ export class PageService {
|
|||||||
throw new BadRequestException('A page cannot be its own parent');
|
throw new BadRequestException('A page cannot be its own parent');
|
||||||
}
|
}
|
||||||
|
|
||||||
let parentPageId = null;
|
await executeTx(this.db, async (trx) => {
|
||||||
if (movedPage.parentPageId === dto.parentPageId) {
|
await this.pageRepo.lockPageHierarchySpaces(
|
||||||
parentPageId = undefined;
|
[movedPage.spaceId],
|
||||||
} else {
|
trx,
|
||||||
// changing the page's parent
|
);
|
||||||
if (dto.parentPageId) {
|
|
||||||
const parentPage = await this.pageRepo.findById(dto.parentPageId);
|
|
||||||
if (
|
|
||||||
!parentPage ||
|
|
||||||
parentPage.deletedAt ||
|
|
||||||
parentPage.spaceId !== movedPage.spaceId
|
|
||||||
) {
|
|
||||||
throw new NotFoundException('Parent page not found');
|
|
||||||
}
|
|
||||||
parentPageId = parentPage.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.pageRepo.updatePage(
|
const currentPage = await this.pageRepo.findById(dto.pageId, { trx });
|
||||||
{
|
if (!currentPage || currentPage.deletedAt) {
|
||||||
position: dto.position,
|
throw new NotFoundException('Moved page not found');
|
||||||
parentPageId: parentPageId,
|
}
|
||||||
},
|
if (currentPage.spaceId !== movedPage.spaceId) {
|
||||||
dto.pageId,
|
throw new ConflictException('Page location changed; retry the move');
|
||||||
);
|
}
|
||||||
|
|
||||||
|
let parentPageId = null;
|
||||||
|
if (currentPage.parentPageId === dto.parentPageId) {
|
||||||
|
parentPageId = undefined;
|
||||||
|
} else {
|
||||||
|
if (dto.parentPageId) {
|
||||||
|
const parentPage = await this.pageRepo.findById(dto.parentPageId, {
|
||||||
|
trx,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!parentPage ||
|
||||||
|
parentPage.deletedAt ||
|
||||||
|
parentPage.spaceId !== currentPage.spaceId
|
||||||
|
) {
|
||||||
|
throw new NotFoundException('Parent page not found');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
await this.pageRepo.isPageDescendant(
|
||||||
|
dto.pageId,
|
||||||
|
parentPage.id,
|
||||||
|
trx,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A page cannot be moved under its descendant',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parentPageId = parentPage.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.pageRepo.updatePage(
|
||||||
|
{
|
||||||
|
position: dto.position,
|
||||||
|
parentPageId: parentPageId,
|
||||||
|
},
|
||||||
|
dto.pageId,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPageBreadCrumbs(childPageId: string) {
|
async getPageBreadCrumbs(childPageId: string) {
|
||||||
|
|||||||
@@ -60,16 +60,21 @@ export class ShareSeoController {
|
|||||||
|
|
||||||
const pageId = this.extractPageSlugId(pageSlug);
|
const pageId = this.extractPageSlugId(pageSlug);
|
||||||
|
|
||||||
const share = await this.shareService.getShareForPage(
|
let title: string;
|
||||||
pageId,
|
let searchIndexing = false;
|
||||||
workspace.id,
|
try {
|
||||||
);
|
const shared = await this.shareService.getSharedPage(
|
||||||
|
{ pageId },
|
||||||
if (!share) {
|
workspace.id,
|
||||||
|
{ includeContent: false },
|
||||||
|
);
|
||||||
|
title = shared.page.title;
|
||||||
|
searchIndexing = shared.share.searchIndexing;
|
||||||
|
} catch (err) {
|
||||||
return this.sendIndex(indexFilePath, res);
|
return this.sendIndex(indexFilePath, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawTitle = htmlEscape(share?.sharedPage.title ?? 'untitled');
|
const rawTitle = htmlEscape(title ?? 'untitled');
|
||||||
const metaTitle =
|
const metaTitle =
|
||||||
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}…` : rawTitle;
|
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}…` : rawTitle;
|
||||||
|
|
||||||
@@ -78,7 +83,7 @@ export class ShareSeoController {
|
|||||||
const metaTags = [
|
const metaTags = [
|
||||||
`<meta property="og:title" content="${metaTitle}" />`,
|
`<meta property="og:title" content="${metaTitle}" />`,
|
||||||
`<meta property="twitter:title" content="${metaTitle}" />`,
|
`<meta property="twitter:title" content="${metaTitle}" />`,
|
||||||
!share.searchIndexing ? `<meta name="robots" content="noindex" />` : '',
|
!searchIndexing ? `<meta name="robots" content="noindex" />` : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n ');
|
.join('\n ');
|
||||||
|
|||||||
@@ -110,7 +110,11 @@ export class ShareService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
|
async getSharedPage(
|
||||||
|
dto: ShareInfoDto,
|
||||||
|
workspaceId: string,
|
||||||
|
opts?: { includeContent?: boolean },
|
||||||
|
) {
|
||||||
//TODO: we should resolve the page from the share id
|
//TODO: we should resolve the page from the share id
|
||||||
if (!dto.pageId) throw new NotFoundException('Shared page not found');
|
if (!dto.pageId) throw new NotFoundException('Shared page not found');
|
||||||
|
|
||||||
@@ -120,10 +124,13 @@ export class ShareService {
|
|||||||
throw new NotFoundException('Shared page not found');
|
throw new NotFoundException('Shared page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = await this.pageRepo.findById(dto.pageId, {
|
const includeContent = opts?.includeContent !== false;
|
||||||
includeContent: true,
|
const page = includeContent
|
||||||
includeCreator: true,
|
? await this.pageRepo.findById(dto.pageId, {
|
||||||
});
|
includeContent: true,
|
||||||
|
includeCreator: true,
|
||||||
|
})
|
||||||
|
: await this.pageRepo.findById(dto.pageId);
|
||||||
|
|
||||||
if (!page || page.deletedAt) {
|
if (!page || page.deletedAt) {
|
||||||
throw new NotFoundException('Shared page not found');
|
throw new NotFoundException('Shared page not found');
|
||||||
@@ -137,7 +144,9 @@ export class ShareService {
|
|||||||
throw new NotFoundException('Shared page not found');
|
throw new NotFoundException('Shared page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
page.content = await this.updatePublicAttachments(page);
|
if (includeContent) {
|
||||||
|
page.content = await this.updatePublicAttachments(page);
|
||||||
|
}
|
||||||
|
|
||||||
return { page, share };
|
return { page, share };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
|||||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||||
import { AddSpaceMembersDto } from '../dto/add-space-members.dto';
|
import { AddSpaceMembersDto } from '../dto/add-space-members.dto';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { Space, SpaceMember, User } from '@docmost/db/types/entity.types';
|
import { Space, User } from '@docmost/db/types/entity.types';
|
||||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||||
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
|
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
|
||||||
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
|
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
|
||||||
@@ -218,41 +218,18 @@ export class SpaceMemberService {
|
|||||||
dto: RemoveSpaceMemberDto,
|
dto: RemoveSpaceMemberDto,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
|
const memberTypeId = dto.userId
|
||||||
if (!space) {
|
? { userId: dto.userId }
|
||||||
throw new NotFoundException('Space not found');
|
: dto.groupId
|
||||||
}
|
? { groupId: dto.groupId }
|
||||||
|
: null;
|
||||||
|
|
||||||
let spaceMember: SpaceMember = null;
|
if (!memberTypeId) {
|
||||||
|
|
||||||
if (dto.userId) {
|
|
||||||
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
|
|
||||||
dto.spaceId,
|
|
||||||
{
|
|
||||||
userId: dto.userId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else if (dto.groupId) {
|
|
||||||
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
|
|
||||||
dto.spaceId,
|
|
||||||
{
|
|
||||||
groupId: dto.groupId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Please provide a valid userId or groupId to remove',
|
'Please provide a valid userId or groupId to remove',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!spaceMember) {
|
|
||||||
throw new NotFoundException('Space membership not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (spaceMember.role === SpaceRole.ADMIN) {
|
|
||||||
await this.validateLastAdmin(dto.spaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
let affectedUserIds: string[] = [];
|
let affectedUserIds: string[] = [];
|
||||||
if (dto.userId) {
|
if (dto.userId) {
|
||||||
affectedUserIds = [dto.userId];
|
affectedUserIds = [dto.userId];
|
||||||
@@ -262,7 +239,29 @@ export class SpaceMemberService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await executeTx(this.db, async (trx) => {
|
const { space, spaceMember } = await executeTx(this.db, async (trx) => {
|
||||||
|
const space = await this.spaceRepo.findById(
|
||||||
|
dto.spaceId,
|
||||||
|
workspaceId,
|
||||||
|
{ withLock: true, trx },
|
||||||
|
);
|
||||||
|
if (!space) {
|
||||||
|
throw new NotFoundException('Space not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
|
||||||
|
dto.spaceId,
|
||||||
|
memberTypeId,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
if (!spaceMember) {
|
||||||
|
throw new NotFoundException('Space membership not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spaceMember.role === SpaceRole.ADMIN) {
|
||||||
|
await this.validateLastAdmin(dto.spaceId, trx);
|
||||||
|
}
|
||||||
|
|
||||||
await this.spaceMemberRepo.removeSpaceMemberById(
|
await this.spaceMemberRepo.removeSpaceMemberById(
|
||||||
spaceMember.id,
|
spaceMember.id,
|
||||||
dto.spaceId,
|
dto.spaceId,
|
||||||
@@ -280,6 +279,8 @@ export class SpaceMemberService {
|
|||||||
dto.spaceId,
|
dto.spaceId,
|
||||||
{ trx },
|
{ trx },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return { space, spaceMember };
|
||||||
});
|
});
|
||||||
|
|
||||||
this.auditService.log({
|
this.auditService.log({
|
||||||
@@ -304,48 +305,40 @@ export class SpaceMemberService {
|
|||||||
dto: UpdateSpaceMemberRoleDto,
|
dto: UpdateSpaceMemberRoleDto,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
|
const memberTypeId = dto.userId
|
||||||
if (!space) {
|
? { userId: dto.userId }
|
||||||
throw new NotFoundException('Space not found');
|
: dto.groupId
|
||||||
}
|
? { groupId: dto.groupId }
|
||||||
|
: null;
|
||||||
|
|
||||||
let spaceMember: SpaceMember = null;
|
if (!memberTypeId) {
|
||||||
|
|
||||||
if (dto.userId) {
|
|
||||||
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
|
|
||||||
dto.spaceId,
|
|
||||||
{
|
|
||||||
userId: dto.userId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else if (dto.groupId) {
|
|
||||||
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
|
|
||||||
dto.spaceId,
|
|
||||||
{
|
|
||||||
groupId: dto.groupId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Please provide a valid userId or groupId to remove',
|
'Please provide a valid userId or groupId to remove',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!spaceMember) {
|
const result = await executeTx(this.db, async (trx) => {
|
||||||
throw new NotFoundException('Space membership not found');
|
const space = await this.spaceRepo.findById(
|
||||||
}
|
dto.spaceId,
|
||||||
|
workspaceId,
|
||||||
|
{ withLock: true, trx },
|
||||||
|
);
|
||||||
|
if (!space) {
|
||||||
|
throw new NotFoundException('Space not found');
|
||||||
|
}
|
||||||
|
|
||||||
if (spaceMember.role === dto.role) {
|
const spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
|
||||||
return;
|
dto.spaceId,
|
||||||
}
|
memberTypeId,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
if (!spaceMember) {
|
||||||
|
throw new NotFoundException('Space membership not found');
|
||||||
|
}
|
||||||
|
|
||||||
await executeTx(this.db, async (trx) => {
|
if (spaceMember.role === dto.role) {
|
||||||
await trx
|
return { changed: false, space, spaceMember };
|
||||||
.selectFrom('spaces')
|
}
|
||||||
.select('id')
|
|
||||||
.where('id', '=', dto.spaceId)
|
|
||||||
.forUpdate()
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (spaceMember.role === SpaceRole.ADMIN) {
|
if (spaceMember.role === SpaceRole.ADMIN) {
|
||||||
await this.validateLastAdmin(dto.spaceId, trx);
|
await this.validateLastAdmin(dto.spaceId, trx);
|
||||||
@@ -357,8 +350,16 @@ export class SpaceMemberService {
|
|||||||
dto.spaceId,
|
dto.spaceId,
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return { changed: true, space, spaceMember };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!result.changed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { space, spaceMember } = result;
|
||||||
|
|
||||||
this.auditService.log({
|
this.auditService.log({
|
||||||
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
|
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
|
||||||
resourceType: AuditResource.SPACE_MEMBER,
|
resourceType: AuditResource.SPACE_MEMBER,
|
||||||
@@ -387,7 +388,7 @@ export class SpaceMemberService {
|
|||||||
spaceId,
|
spaceId,
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
if (spaceOwnerCount === 1) {
|
if (spaceOwnerCount <= 1) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'There must be at least one space admin with full access',
|
'There must be at least one space admin with full access',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -747,44 +747,61 @@ export class WorkspaceService {
|
|||||||
userRoleDto: UpdateWorkspaceUserRoleDto,
|
userRoleDto: UpdateWorkspaceUserRoleDto,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
) {
|
) {
|
||||||
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
|
|
||||||
|
|
||||||
const newRole = userRoleDto.role.toLowerCase();
|
const newRole = userRoleDto.role.toLowerCase();
|
||||||
|
const result = await executeTx(this.db, async (trx) => {
|
||||||
|
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||||
|
withLock: true,
|
||||||
|
trx,
|
||||||
|
});
|
||||||
|
if (!workspace) {
|
||||||
|
throw new NotFoundException('Workspace not found');
|
||||||
|
}
|
||||||
|
|
||||||
if (!user) {
|
const user = await this.userRepo.findById(
|
||||||
throw new BadRequestException('Workspace member not found');
|
userRoleDto.userId,
|
||||||
}
|
workspaceId,
|
||||||
|
{ trx },
|
||||||
// prevent ADMIN from managing OWNER role
|
|
||||||
if (
|
|
||||||
isAdminActingOnOwner(authUser.role, newRole) ||
|
|
||||||
isAdminActingOnOwner(authUser.role, user.role)
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.role === newRole) {
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
|
|
||||||
UserRole.OWNER,
|
|
||||||
workspaceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'There must be at least one workspace owner',
|
|
||||||
);
|
);
|
||||||
|
if (!user) {
|
||||||
|
throw new BadRequestException('Workspace member not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isAdminActingOnOwner(authUser.role, newRole) ||
|
||||||
|
isAdminActingOnOwner(authUser.role, user.role)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === newRole) {
|
||||||
|
return { changed: false, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
user.role === UserRole.OWNER &&
|
||||||
|
!user.deletedAt &&
|
||||||
|
!user.deactivatedAt
|
||||||
|
) {
|
||||||
|
await this.validateLastWorkspaceOwner(workspaceId, trx);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userRepo.updateUser(
|
||||||
|
{
|
||||||
|
role: newRole,
|
||||||
|
},
|
||||||
|
user.id,
|
||||||
|
workspaceId,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { changed: true, user };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.changed) {
|
||||||
|
return result.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.userRepo.updateUser(
|
const { user } = result;
|
||||||
{
|
|
||||||
role: newRole,
|
|
||||||
},
|
|
||||||
user.id,
|
|
||||||
workspaceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.auditService.log({
|
this.auditService.log({
|
||||||
event: AuditEvent.USER_ROLE_CHANGED,
|
event: AuditEvent.USER_ROLE_CHANGED,
|
||||||
@@ -848,40 +865,38 @@ export class WorkspaceService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const user = await this.userRepo.findById(userId, workspaceId);
|
const user = await executeTx(this.db, async (trx) => {
|
||||||
|
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||||
|
withLock: true,
|
||||||
|
trx,
|
||||||
|
});
|
||||||
|
if (!workspace) {
|
||||||
|
throw new NotFoundException('Workspace not found');
|
||||||
|
}
|
||||||
|
|
||||||
if (!user || user.deletedAt) {
|
const user = await this.userRepo.findById(userId, workspaceId, { trx });
|
||||||
throw new BadRequestException('Workspace member not found');
|
if (!user || user.deletedAt) {
|
||||||
}
|
throw new BadRequestException('Workspace member not found');
|
||||||
|
}
|
||||||
|
|
||||||
if (user.deactivatedAt) {
|
if (user.deactivatedAt) {
|
||||||
throw new BadRequestException('User is already deactivated');
|
throw new BadRequestException('User is already deactivated');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authUser.id === userId) {
|
if (authUser.id === userId) {
|
||||||
throw new BadRequestException('You cannot deactivate yourself');
|
throw new BadRequestException('You cannot deactivate yourself');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAdminActingOnOwner(authUser.role, user.role)) {
|
if (isAdminActingOnOwner(authUser.role, user.role)) {
|
||||||
throw new BadRequestException(
|
|
||||||
'You cannot deactivate a user with owner role',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.role === UserRole.OWNER) {
|
|
||||||
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
|
|
||||||
UserRole.OWNER,
|
|
||||||
workspaceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (workspaceOwnerCount === 1) {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'There must be at least one workspace owner',
|
'You cannot deactivate a user with owner role',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await executeTx(this.db, async (trx) => {
|
if (user.role === UserRole.OWNER) {
|
||||||
|
await this.validateLastWorkspaceOwner(workspaceId, trx);
|
||||||
|
}
|
||||||
|
|
||||||
await this.userRepo.updateUser(
|
await this.userRepo.updateUser(
|
||||||
{ deactivatedAt: new Date() },
|
{ deactivatedAt: new Date() },
|
||||||
userId,
|
userId,
|
||||||
@@ -889,6 +904,8 @@ export class WorkspaceService {
|
|||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
|
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
|
||||||
|
|
||||||
|
return user;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.auditService.log({
|
this.auditService.log({
|
||||||
@@ -951,32 +968,34 @@ export class WorkspaceService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const user = await this.userRepo.findById(userId, workspaceId);
|
const user = await executeTx(this.db, async (trx) => {
|
||||||
|
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||||
|
withLock: true,
|
||||||
|
trx,
|
||||||
|
});
|
||||||
|
if (!workspace) {
|
||||||
|
throw new NotFoundException('Workspace not found');
|
||||||
|
}
|
||||||
|
|
||||||
if (!user || user.deletedAt) {
|
const user = await this.userRepo.findById(userId, workspaceId, { trx });
|
||||||
throw new BadRequestException('Workspace member not found');
|
if (!user || user.deletedAt) {
|
||||||
}
|
throw new BadRequestException('Workspace member not found');
|
||||||
|
}
|
||||||
|
|
||||||
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
|
if (authUser.id === userId) {
|
||||||
UserRole.OWNER,
|
throw new BadRequestException('You cannot delete yourself');
|
||||||
workspaceId,
|
}
|
||||||
);
|
|
||||||
|
|
||||||
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
|
if (isAdminActingOnOwner(authUser.role, user.role)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'There must be at least one workspace owner',
|
'You cannot delete a user with owner role',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authUser.id === userId) {
|
if (user.role === UserRole.OWNER && !user.deactivatedAt) {
|
||||||
throw new BadRequestException('You cannot delete yourself');
|
await this.validateLastWorkspaceOwner(workspaceId, trx);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAdminActingOnOwner(authUser.role, user.role)) {
|
|
||||||
throw new BadRequestException('You cannot delete a user with owner role');
|
|
||||||
}
|
|
||||||
|
|
||||||
await executeTx(this.db, async (trx) => {
|
|
||||||
await this.userRepo.updateUser(
|
await this.userRepo.updateUser(
|
||||||
{
|
{
|
||||||
name: 'Deleted user',
|
name: 'Deleted user',
|
||||||
@@ -1009,6 +1028,8 @@ export class WorkspaceService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
|
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
|
||||||
|
|
||||||
|
return user;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.auditService.log({
|
this.auditService.log({
|
||||||
@@ -1030,4 +1051,20 @@ export class WorkspaceService {
|
|||||||
// empty
|
// empty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async validateLastWorkspaceOwner(
|
||||||
|
workspaceId: string,
|
||||||
|
trx: KyselyTransaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
|
||||||
|
UserRole.OWNER,
|
||||||
|
workspaceId,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
if (workspaceOwnerCount <= 1) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'There must be at least one workspace owner',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
|||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { EventName } from '../../../common/events/event.contants';
|
import { EventName } from '../../../common/events/event.contants';
|
||||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageRepo {
|
export class PageRepo {
|
||||||
@@ -162,6 +161,22 @@ export class PageRepo {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async lockPageHierarchySpaces(
|
||||||
|
spaceIds: string[],
|
||||||
|
trx: KyselyTransaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const sortedSpaceIds = [...new Set(spaceIds)].sort();
|
||||||
|
|
||||||
|
for (const spaceId of sortedSpaceIds) {
|
||||||
|
await sql`
|
||||||
|
SELECT pg_advisory_xact_lock(
|
||||||
|
hashtext('page-hierarchy'),
|
||||||
|
hashtext(${spaceId})
|
||||||
|
)
|
||||||
|
`.execute(trx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async insertPage(
|
async insertPage(
|
||||||
insertablePage: InsertablePage,
|
insertablePage: InsertablePage,
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
@@ -490,9 +505,9 @@ export class PageRepo {
|
|||||||
|
|
||||||
async getPageAndDescendants(
|
async getPageAndDescendants(
|
||||||
parentPageId: string,
|
parentPageId: string,
|
||||||
opts: { includeContent: boolean },
|
opts: { includeContent: boolean; trx?: KyselyTransaction },
|
||||||
) {
|
) {
|
||||||
return this.db
|
return dbOrTx(this.db, opts.trx)
|
||||||
.withRecursive('page_hierarchy', (db) =>
|
.withRecursive('page_hierarchy', (db) =>
|
||||||
db
|
db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
@@ -536,6 +551,36 @@ export class PageRepo {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async isPageDescendant(
|
||||||
|
ancestorPageId: string,
|
||||||
|
descendantPageId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await dbOrTx(this.db, trx)
|
||||||
|
.withRecursive('page_ancestors', (db) =>
|
||||||
|
db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['id', 'parentPageId'])
|
||||||
|
.where('id', '=', descendantPageId)
|
||||||
|
.union((exp) =>
|
||||||
|
exp
|
||||||
|
.selectFrom('pages as parent')
|
||||||
|
.select(['parent.id', 'parent.parentPageId'])
|
||||||
|
.innerJoin(
|
||||||
|
'page_ancestors as ancestor',
|
||||||
|
'ancestor.parentPageId',
|
||||||
|
'parent.id',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.selectFrom('page_ancestors')
|
||||||
|
.select('id')
|
||||||
|
.where('id', '=', ancestorPageId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return Boolean(result);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get page and all descendants, excluding restricted pages and their subtrees.
|
* Get page and all descendants, excluding restricted pages and their subtrees.
|
||||||
* More efficient than getPageAndDescendants + filtering because:
|
* More efficient than getPageAndDescendants + filtering because:
|
||||||
@@ -607,29 +652,6 @@ export class PageRepo {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async nextPagePosition(
|
|
||||||
spaceId: string,
|
|
||||||
parentPageId?: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const lastPageQuery = this.db
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select(['position'])
|
|
||||||
.where('spaceId', '=', spaceId)
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.orderBy('position', (ob) => ob.collate('C').desc())
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const lastPage = parentPageId
|
|
||||||
? await lastPageQuery
|
|
||||||
.where('parentPageId', '=', parentPageId)
|
|
||||||
.executeTakeFirst()
|
|
||||||
: await lastPageQuery
|
|
||||||
.where('parentPageId', 'is', null)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
return generateJitteredKeyBetween(lastPage?.position ?? null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All pages of a space excluding restricted subtrees.
|
* All pages of a space excluding restricted subtrees.
|
||||||
* Used by public spaces; a restricted page hides its whole subtree.
|
* Used by public spaces; a restricted page hides its whole subtree.
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ export class SpaceRepo {
|
|||||||
async findById(
|
async findById(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
|
opts?: {
|
||||||
|
includeMemberCount?: boolean;
|
||||||
|
withLock?: boolean;
|
||||||
|
trx?: KyselyTransaction;
|
||||||
|
},
|
||||||
): Promise<Space> {
|
): Promise<Space> {
|
||||||
const db = dbOrTx(this.db, opts?.trx);
|
const db = dbOrTx(this.db, opts?.trx);
|
||||||
|
|
||||||
@@ -41,6 +45,11 @@ export class SpaceRepo {
|
|||||||
} else {
|
} else {
|
||||||
query = query.where(sql`LOWER(slug)`, '=', sql`LOWER(${spaceId})`);
|
query = query.where(sql`LOWER(slug)`, '=', sql`LOWER(${spaceId})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts?.withLock && opts?.trx) {
|
||||||
|
query = query.forUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
return query.executeTakeFirst();
|
return query.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -145,12 +145,16 @@ export class UserRepo {
|
|||||||
async roleCountByWorkspaceId(
|
async roleCountByWorkspaceId(
|
||||||
role: string,
|
role: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const { count } = await this.db
|
const db = dbOrTx(this.db, trx);
|
||||||
|
const { count } = await db
|
||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select((eb) => eb.fn.count('role').as('count'))
|
.select((eb) => eb.fn.count('role').as('count'))
|
||||||
.where('role', '=', role)
|
.where('role', '=', role)
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
|
.where('deactivatedAt', 'is', null)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
return count as number;
|
return count as number;
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { Json, Timestamp, Generated } from '@docmost/db/types/db';
|
|
||||||
|
|
||||||
export interface ConfluenceApiImports {
|
|
||||||
id: Generated<string>;
|
|
||||||
fileTaskId: string;
|
|
||||||
siteUrl: string;
|
|
||||||
authType: string;
|
|
||||||
authEmail: string | null;
|
|
||||||
authToken: string | null;
|
|
||||||
authUsername: string | null;
|
|
||||||
totalSpaces: Generated<number>;
|
|
||||||
importedSpaces: Generated<number>;
|
|
||||||
totalPages: Generated<number>;
|
|
||||||
importedPages: Generated<number>;
|
|
||||||
totalUsers: Generated<number>;
|
|
||||||
importedUsers: Generated<number>;
|
|
||||||
totalAttachments: Generated<number>;
|
|
||||||
importedAttachments: Generated<number>;
|
|
||||||
totalLabels: Generated<number>;
|
|
||||||
importedLabels: Generated<number>;
|
|
||||||
totalGroups: Generated<number>;
|
|
||||||
importedGroups: Generated<number>;
|
|
||||||
totalRestrictedPages: Generated<number>;
|
|
||||||
importedRestrictedPages: Generated<number>;
|
|
||||||
idMapping: Generated<Json>;
|
|
||||||
warnings: Generated<Json>;
|
|
||||||
currentPhase: string | null;
|
|
||||||
cancelled: Generated<boolean>;
|
|
||||||
spaceKeys: Generated<Json>;
|
|
||||||
workspaceId: string;
|
|
||||||
creatorId: string | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||||
import { ConfluenceApiImports } from './custom.types';
|
|
||||||
|
|
||||||
export interface DbInterface extends DB {
|
export interface DbInterface extends DB {
|
||||||
pageEmbeddings: PageEmbeddings;
|
pageEmbeddings: PageEmbeddings;
|
||||||
confluenceApiImports: ConfluenceApiImports;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: d106538610...5e7120dcc8
@@ -214,13 +214,6 @@ export class EnvironmentService {
|
|||||||
return !this.isCloud();
|
return !this.isCloud();
|
||||||
}
|
}
|
||||||
|
|
||||||
isBetaConfluenceImporter(): boolean {
|
|
||||||
const flag = this.configService
|
|
||||||
.get<string>('BETA_CONFLUENCE_IMPORTER', 'false')
|
|
||||||
.toLowerCase();
|
|
||||||
return flag === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
getStripePublishableKey(): string {
|
getStripePublishableKey(): string {
|
||||||
return this.configService.get<string>('STRIPE_PUBLISHABLE_KEY');
|
return this.configService.get<string>('STRIPE_PUBLISHABLE_KEY');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { ImportModule } from './import.module';
|
|
||||||
import { FileTaskProcessor } from './processors/file-task.processor';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [ImportModule],
|
|
||||||
providers: [FileTaskProcessor],
|
|
||||||
})
|
|
||||||
export class ImportProcessorModule {}
|
|
||||||
@@ -3,13 +3,19 @@ import { ImportService } from './services/import.service';
|
|||||||
import { ImportController } from './import.controller';
|
import { ImportController } from './import.controller';
|
||||||
import { StorageModule } from '../storage/storage.module';
|
import { StorageModule } from '../storage/storage.module';
|
||||||
import { FileImportTaskService } from './services/file-import-task.service';
|
import { FileImportTaskService } from './services/file-import-task.service';
|
||||||
|
import { FileTaskProcessor } from './processors/file-task.processor';
|
||||||
import { ImportAttachmentService } from './services/import-attachment.service';
|
import { ImportAttachmentService } from './services/import-attachment.service';
|
||||||
import { FileTaskController } from './file-task.controller';
|
import { FileTaskController } from './file-task.controller';
|
||||||
import { PageModule } from '../../core/page/page.module';
|
import { PageModule } from '../../core/page/page.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [ImportService, FileImportTaskService, ImportAttachmentService],
|
providers: [
|
||||||
exports: [ImportService, ImportAttachmentService, FileImportTaskService],
|
ImportService,
|
||||||
|
FileImportTaskService,
|
||||||
|
FileTaskProcessor,
|
||||||
|
ImportAttachmentService,
|
||||||
|
],
|
||||||
|
exports: [ImportService, ImportAttachmentService],
|
||||||
controllers: [ImportController, FileTaskController],
|
controllers: [ImportController, FileTaskController],
|
||||||
imports: [StorageModule, PageModule],
|
imports: [StorageModule, PageModule],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
case QueueJob.IMPORT_TASK:
|
case QueueJob.IMPORT_TASK:
|
||||||
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
|
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
|
||||||
break;
|
break;
|
||||||
case QueueJob.CONFLUENCE_API_IMPORT:
|
|
||||||
await this.processConfluenceApiImport(job.data.fileTaskId);
|
|
||||||
break;
|
|
||||||
case QueueJob.PDF_EXPORT_TASK:
|
case QueueJob.PDF_EXPORT_TASK:
|
||||||
await this.processExportTask(job.data.fileTaskId);
|
await this.processExportTask(job.data.fileTaskId);
|
||||||
break;
|
break;
|
||||||
@@ -52,19 +49,6 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getConfluenceApiImportService() {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const mod = require('./../../../ee/confluence-api-import/confluence-api-import.service');
|
|
||||||
return this.moduleRef.get(mod.ConfluenceApiImportService, {
|
|
||||||
strict: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processConfluenceApiImport(fileTaskId: string): Promise<void> {
|
|
||||||
const service = this.getConfluenceApiImportService();
|
|
||||||
await service.processImport(fileTaskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processExportTask(fileTaskId: string): Promise<void> {
|
private async processExportTask(fileTaskId: string): Promise<void> {
|
||||||
const pdfExportService = this.getPdfExportService();
|
const pdfExportService = this.getPdfExportService();
|
||||||
await pdfExportService.generateAndStorePdf(fileTaskId);
|
await pdfExportService.generateAndStorePdf(fileTaskId);
|
||||||
@@ -93,8 +77,6 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
await this.handleFailedImportJob(job);
|
await this.handleFailedImportJob(job);
|
||||||
} else if (job.name === QueueJob.PDF_EXPORT_TASK) {
|
} else if (job.name === QueueJob.PDF_EXPORT_TASK) {
|
||||||
await this.handleFailedExportJob(job);
|
await this.handleFailedExportJob(job);
|
||||||
} else if (job.name === QueueJob.CONFLUENCE_API_IMPORT) {
|
|
||||||
await this.handleFailedExportJob(job);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -452,7 +452,16 @@ export class ImportAttachmentService {
|
|||||||
|
|
||||||
const audioExtensions = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.webm', '.flac', '.aac']);
|
const audioExtensions = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.webm', '.flac', '.aac']);
|
||||||
|
|
||||||
if (ext === '.mp4') {
|
if (ext === '.pdf') {
|
||||||
|
const $pdf = $('<div>')
|
||||||
|
.attr('data-type', 'pdf')
|
||||||
|
.attr('src', apiFilePath)
|
||||||
|
.attr('data-attachment-id', attachmentId)
|
||||||
|
.attr('width', '800')
|
||||||
|
.attr('height', '600');
|
||||||
|
$a.replaceWith($pdf);
|
||||||
|
unwrapFromParagraph($, $pdf);
|
||||||
|
} else if (ext === '.mp4') {
|
||||||
const $video = $('<video>')
|
const $video = $('<video>')
|
||||||
.attr('src', apiFilePath)
|
.attr('src', apiFilePath)
|
||||||
.attr('data-attachment-id', attachmentId)
|
.attr('data-attachment-id', attachmentId)
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { nodeIdFromConfluenceAnchor } from './confluence-anchor-id';
|
|
||||||
|
|
||||||
describe('nodeIdFromConfluenceAnchor', () => {
|
|
||||||
it('is deterministic for the same (pageId, anchorName)', () => {
|
|
||||||
const a = nodeIdFromConfluenceAnchor('page-1', 'My Anchor');
|
|
||||||
const b = nodeIdFromConfluenceAnchor('page-1', 'My Anchor');
|
|
||||||
expect(a).toBe(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns different ids when the anchor name differs', () => {
|
|
||||||
const a = nodeIdFromConfluenceAnchor('page-1', 'one');
|
|
||||||
const b = nodeIdFromConfluenceAnchor('page-1', 'two');
|
|
||||||
expect(a).not.toBe(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns different ids when the pageId differs', () => {
|
|
||||||
const a = nodeIdFromConfluenceAnchor('page-1', 'same');
|
|
||||||
const b = nodeIdFromConfluenceAnchor('page-2', 'same');
|
|
||||||
expect(a).not.toBe(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns exactly 12 lowercase a-z characters', () => {
|
|
||||||
const id = nodeIdFromConfluenceAnchor('page-xyz', 'Section · 1');
|
|
||||||
expect(id).toHaveLength(12);
|
|
||||||
expect(id).toMatch(/^[a-z]{12}$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats an empty anchor name as a valid input', () => {
|
|
||||||
const id = nodeIdFromConfluenceAnchor('page-1', '');
|
|
||||||
expect(id).toMatch(/^[a-z]{12}$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { createHash } from 'crypto';
|
|
||||||
|
|
||||||
// Matches the alphabet used by generateNodeId() in
|
|
||||||
// packages/editor-ext/src/lib/utils.ts (customAlphabet from nanoid).
|
|
||||||
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz';
|
|
||||||
const NODE_ID_LENGTH = 12;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a deterministic 12-character nodeId for a Confluence anchor.
|
|
||||||
* The same (pageId, anchorName) pair always produces the same result, so
|
|
||||||
* cross-page anchor links resolve to the anchor target without a
|
|
||||||
* precomputed map. The output uses the same alphabet and length as
|
|
||||||
* generateNodeId() from @docmost/editor-ext, so it is interchangeable
|
|
||||||
* with editor-generated nodeIds.
|
|
||||||
*/
|
|
||||||
export function nodeIdFromConfluenceAnchor(
|
|
||||||
pageId: string,
|
|
||||||
anchorName: string,
|
|
||||||
): string {
|
|
||||||
const digest = createHash('sha256')
|
|
||||||
.update(`${pageId}#${anchorName}`)
|
|
||||||
.digest();
|
|
||||||
let out = '';
|
|
||||||
for (let i = 0; i < NODE_ID_LENGTH; i++) {
|
|
||||||
out += ALPHABET[digest[i] % ALPHABET.length];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { parseConfluenceEmojiId } from './confluence-emoji';
|
|
||||||
|
|
||||||
describe('parseConfluenceEmojiId', () => {
|
|
||||||
it('parses a single code point id', () => {
|
|
||||||
expect(parseConfluenceEmojiId('1f600')).toBe('😀');
|
|
||||||
expect(parseConfluenceEmojiId('1F600')).toBe('😀');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses a country flag (two regional indicator code points)', () => {
|
|
||||||
expect(parseConfluenceEmojiId('1f1f3-1f1ec')).toBe('🇳🇬');
|
|
||||||
expect(parseConfluenceEmojiId('1f1fa-1f1f8')).toBe('🇺🇸');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses a ZWJ sequence (three code points)', () => {
|
|
||||||
expect(parseConfluenceEmojiId('1f468-200d-1f4bb')).toBe('👨💻');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses a five-component family ZWJ sequence', () => {
|
|
||||||
// 👨👩👧👦 = man, ZWJ, woman, ZWJ, girl, ZWJ, boy
|
|
||||||
expect(parseConfluenceEmojiId('1f468-200d-1f469-200d-1f467-200d-1f466')).toBe(
|
|
||||||
'👨👩👧👦',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for missing input', () => {
|
|
||||||
expect(parseConfluenceEmojiId(undefined)).toBeNull();
|
|
||||||
expect(parseConfluenceEmojiId(null)).toBeNull();
|
|
||||||
expect(parseConfluenceEmojiId('')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when any segment is not pure hex', () => {
|
|
||||||
expect(parseConfluenceEmojiId('1f600-NG')).toBeNull();
|
|
||||||
expect(parseConfluenceEmojiId('not-hex')).toBeNull();
|
|
||||||
expect(parseConfluenceEmojiId('1f600--1f1ec')).toBeNull();
|
|
||||||
expect(parseConfluenceEmojiId('1f600 1f1ec')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when a segment parses to a non-positive value', () => {
|
|
||||||
expect(parseConfluenceEmojiId('0')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for code points outside the valid Unicode range', () => {
|
|
||||||
// 0x110000 is one past the highest valid code point.
|
|
||||||
expect(parseConfluenceEmojiId('110000')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* Parse a Confluence emoji id (hex code points joined by hyphens) into a
|
|
||||||
* Unicode string. Confluence emits ids in both single- and multi-code-point
|
|
||||||
* forms:
|
|
||||||
*
|
|
||||||
* "1f600" → "😀"
|
|
||||||
* "1f1f3-1f1ec" → "🇳🇬" (flag: Nigeria)
|
|
||||||
* "1f468-200d-1f4bb" → "👨💻" (man technologist, ZWJ sequence)
|
|
||||||
*
|
|
||||||
* Returns null when the input is missing, empty, or doesn't parse cleanly as
|
|
||||||
* hyphen-separated hex code points.
|
|
||||||
*/
|
|
||||||
export function parseConfluenceEmojiId(
|
|
||||||
raw: string | undefined | null,
|
|
||||||
): string | null {
|
|
||||||
if (!raw) return null;
|
|
||||||
const parts = raw.split('-');
|
|
||||||
if (parts.length === 0) return null;
|
|
||||||
if (!parts.every((p) => /^[0-9a-fA-F]+$/.test(p))) return null;
|
|
||||||
const codePoints = parts.map((p) => parseInt(p, 16));
|
|
||||||
if (codePoints.some((cp) => !Number.isFinite(cp) || cp <= 0)) return null;
|
|
||||||
try {
|
|
||||||
return String.fromCodePoint(...codePoints);
|
|
||||||
} catch {
|
|
||||||
// Out-of-range code points throw RangeError on String.fromCodePoint.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { mapConfluenceHighlightColor } from './confluence-highlight-color';
|
|
||||||
|
|
||||||
describe('mapConfluenceHighlightColor', () => {
|
|
||||||
it('maps named DC colours to the Docmost table palette', () => {
|
|
||||||
expect(mapConfluenceHighlightColor('grey')).toEqual({
|
|
||||||
color: '#eaecef',
|
|
||||||
name: 'gray',
|
|
||||||
});
|
|
||||||
expect(mapConfluenceHighlightColor('red')).toEqual({
|
|
||||||
color: '#ffbead',
|
|
||||||
name: 'red',
|
|
||||||
});
|
|
||||||
expect(mapConfluenceHighlightColor('yellow')).toEqual({
|
|
||||||
color: '#fef1b4',
|
|
||||||
name: 'yellow',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is case- and whitespace-insensitive', () => {
|
|
||||||
expect(mapConfluenceHighlightColor(' Grey ')).toEqual({
|
|
||||||
color: '#eaecef',
|
|
||||||
name: 'gray',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps teal to the closest Docmost colour', () => {
|
|
||||||
expect(mapConfluenceHighlightColor('teal')).toEqual({
|
|
||||||
color: '#b4d5ff',
|
|
||||||
name: 'blue',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes hex values through untouched', () => {
|
|
||||||
expect(mapConfluenceHighlightColor('#f4f5f7')).toEqual({
|
|
||||||
color: '#f4f5f7',
|
|
||||||
});
|
|
||||||
expect(mapConfluenceHighlightColor('#4c9aff')).toEqual({
|
|
||||||
color: '#4c9aff',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
const CONFLUENCE_HIGHLIGHT_TO_DOCMOST: Record<
|
|
||||||
string,
|
|
||||||
{ color: string; name: string }
|
|
||||||
> = {
|
|
||||||
grey: { color: '#eaecef', name: 'gray' },
|
|
||||||
gray: { color: '#eaecef', name: 'gray' },
|
|
||||||
blue: { color: '#b4d5ff', name: 'blue' },
|
|
||||||
teal: { color: '#b4d5ff', name: 'blue' },
|
|
||||||
green: { color: '#acf5d2', name: 'green' },
|
|
||||||
yellow: { color: '#fef1b4', name: 'yellow' },
|
|
||||||
red: { color: '#ffbead', name: 'red' },
|
|
||||||
purple: { color: '#c1b7f2', name: 'purple' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function mapConfluenceHighlightColor(colour: string): {
|
|
||||||
color: string;
|
|
||||||
name?: string;
|
|
||||||
} {
|
|
||||||
return (
|
|
||||||
CONFLUENCE_HIGHLIGHT_TO_DOCMOST[colour.trim().toLowerCase()] ?? {
|
|
||||||
color: colour,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import { load } from 'cheerio';
|
|
||||||
import { applyConfluenceMarginLeftIndent } from './confluence-indent';
|
|
||||||
|
|
||||||
function run(html: string): string {
|
|
||||||
const $ = load(html);
|
|
||||||
applyConfluenceMarginLeftIndent($, $.root());
|
|
||||||
// cheerio's html() includes <html><body>; return the body's inner HTML so
|
|
||||||
// tests can assert on the meaningful portion.
|
|
||||||
return $('body').html() ?? $.html();
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('applyConfluenceMarginLeftIndent', () => {
|
|
||||||
describe('Confluence Cloud (30 px per level, max 6)', () => {
|
|
||||||
it('maps 30/60/90/120/150/180 px to data-indent 1..6', () => {
|
|
||||||
const html =
|
|
||||||
'<p style="margin-left: 30.0px;">L1</p>' +
|
|
||||||
'<p style="margin-left: 60.0px;">L2</p>' +
|
|
||||||
'<p style="margin-left: 90.0px;">L3</p>' +
|
|
||||||
'<p style="margin-left: 120.0px;">L4</p>' +
|
|
||||||
'<p style="margin-left: 150.0px;">L5</p>' +
|
|
||||||
'<p style="margin-left: 180.0px;">L6</p>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toContain('<p data-indent="1">L1</p>');
|
|
||||||
expect(out).toContain('<p data-indent="2">L2</p>');
|
|
||||||
expect(out).toContain('<p data-indent="3">L3</p>');
|
|
||||||
expect(out).toContain('<p data-indent="4">L4</p>');
|
|
||||||
expect(out).toContain('<p data-indent="5">L5</p>');
|
|
||||||
expect(out).toContain('<p data-indent="6">L6</p>');
|
|
||||||
expect(out).not.toContain('margin-left');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Confluence Data Center (40 px per level, no upper bound)', () => {
|
|
||||||
it('maps 40/80/120/160/200/240 px to data-indent 1..6', () => {
|
|
||||||
const html =
|
|
||||||
'<p style="margin-left: 40.0px;">one</p>' +
|
|
||||||
'<p style="margin-left: 80.0px;">two</p>' +
|
|
||||||
'<p style="margin-left: 120.0px;">three</p>' +
|
|
||||||
'<p style="margin-left: 160.0px;">four</p>' +
|
|
||||||
'<p style="margin-left: 200.0px;">five</p>' +
|
|
||||||
'<p style="margin-left: 240.0px;">six</p>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toContain('<p data-indent="1">one</p>');
|
|
||||||
expect(out).toContain('<p data-indent="2">two</p>');
|
|
||||||
expect(out).toContain('<p data-indent="3">three</p>');
|
|
||||||
expect(out).toContain('<p data-indent="4">four</p>');
|
|
||||||
expect(out).toContain('<p data-indent="5">five</p>');
|
|
||||||
expect(out).toContain('<p data-indent="6">six</p>');
|
|
||||||
expect(out).not.toContain('margin-left');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clamps DC levels above 8 down to 8', () => {
|
|
||||||
const html =
|
|
||||||
'<p style="margin-left: 320.0px;">L8</p>' +
|
|
||||||
'<p style="margin-left: 360.0px;">L9</p>' +
|
|
||||||
'<p style="margin-left: 600.0px;">L15</p>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toContain('<p data-indent="8">L8</p>');
|
|
||||||
expect(out).toContain('<p data-indent="8">L9</p>');
|
|
||||||
expect(out).toContain('<p data-indent="8">L15</p>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('headings', () => {
|
|
||||||
it('handles indent on h1-h6 the same way as paragraphs', () => {
|
|
||||||
const html =
|
|
||||||
'<h1 style="margin-left: 30px;">a</h1>' +
|
|
||||||
'<h6 style="margin-left: 90px;">b</h6>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toContain('<h1 data-indent="1">a</h1>');
|
|
||||||
expect(out).toContain('<h6 data-indent="3">b</h6>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('style attribute handling', () => {
|
|
||||||
it('strips margin-left but preserves other inline styles', () => {
|
|
||||||
const html =
|
|
||||||
'<p style="color: red; margin-left: 30px; font-weight: bold;">x</p>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toMatch(/<p style="color: red;\s+font-weight: bold;?" data-indent="1">x<\/p>/);
|
|
||||||
expect(out).not.toContain('margin-left');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes the style attribute entirely when only margin-left was set', () => {
|
|
||||||
// Two values so GCD detection sees a real unit (60 px) instead of
|
|
||||||
// collapsing to the lone value. The point of this test is the style
|
|
||||||
// attribute being stripped, not the level number.
|
|
||||||
const html =
|
|
||||||
'<p style="margin-left: 60px;">x</p>' +
|
|
||||||
'<p style="margin-left: 120px;">y</p>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toContain('<p data-indent="1">x</p>');
|
|
||||||
expect(out).toContain('<p data-indent="2">y</p>');
|
|
||||||
expect(out).not.toContain('style=');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('scope and edge cases', () => {
|
|
||||||
it('leaves elements without margin-left untouched', () => {
|
|
||||||
const html = '<p>plain</p><h2>heading</h2>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toBe('<p>plain</p><h2>heading</h2>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not touch divs, spans, or list items', () => {
|
|
||||||
const html =
|
|
||||||
'<div style="margin-left: 30px;">div</div>' +
|
|
||||||
'<li style="margin-left: 30px;">li</li>' +
|
|
||||||
'<span style="margin-left: 30px;">span</span>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).not.toContain('data-indent');
|
|
||||||
expect(out).toContain('margin-left: 30px');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores zero, negative, and unparseable margin-left values', () => {
|
|
||||||
const html =
|
|
||||||
'<p style="margin-left: 0px;">zero</p>' +
|
|
||||||
'<p style="margin-left: -30px;">neg</p>' +
|
|
||||||
'<p style="margin-left: auto;">auto</p>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).not.toContain('data-indent');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('honors an explicit pxPerLevel override', () => {
|
|
||||||
// Mixed Cloud-and-DC nominal values forced to 40 px/level interpretation.
|
|
||||||
const $ = load(
|
|
||||||
'<p style="margin-left: 40px;">a</p>' +
|
|
||||||
'<p style="margin-left: 80px;">b</p>',
|
|
||||||
);
|
|
||||||
applyConfluenceMarginLeftIndent($, $.root(), { pxPerLevel: 40 });
|
|
||||||
const out = $('body').html() ?? '';
|
|
||||||
expect(out).toContain('<p data-indent="1">a</p>');
|
|
||||||
expect(out).toContain('<p data-indent="2">b</p>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a no-op when no indented elements are present', () => {
|
|
||||||
const html = '<p>hi</p>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toBe('<p>hi</p>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles a single ambiguous value by clamping to level 1', () => {
|
|
||||||
// GCD of a single value is the value itself, so 120 / 120 = 1.
|
|
||||||
const html = '<p style="margin-left: 120px;">only</p>';
|
|
||||||
const out = run(html);
|
|
||||||
expect(out).toContain('<p data-indent="1">only</p>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Cheerio, CheerioAPI } from 'cheerio';
|
|
||||||
|
|
||||||
// Maximum indent level supported by the Indent editor extension (see
|
|
||||||
// packages/editor-ext/src/lib/indent.ts). Values above this clamp down.
|
|
||||||
const MAX_INDENT_LEVEL = 8;
|
|
||||||
const MARGIN_LEFT_RE = /margin-left\s*:\s*(-?\d*\.?\d+)\s*px/i;
|
|
||||||
const MARGIN_LEFT_STRIP_RE = /margin-left\s*:\s*-?\d*\.?\d+\s*px\s*;?/i;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confluence encodes paragraph indent as inline `style="margin-left: Npx"`.
|
|
||||||
* The per-level pixel value differs by edition: Cloud uses 30 (max 6 levels),
|
|
||||||
* Data Center uses 40 (no upper limit). The HTML-export ZIP path has no
|
|
||||||
* edition information available, so we auto-detect the per-level unit from
|
|
||||||
* the GCD of all margin-left values in the document. The API converter can
|
|
||||||
* pass `pxPerLevel` explicitly when the edition is known.
|
|
||||||
*
|
|
||||||
* Levels are written to `data-indent` for the TipTap Indent extension to
|
|
||||||
* pick up; the margin-left style is stripped from the element so the
|
|
||||||
* normalized indent doesn't double up with the editor's own indent padding.
|
|
||||||
*/
|
|
||||||
export function applyConfluenceMarginLeftIndent(
|
|
||||||
$: CheerioAPI,
|
|
||||||
$root: Cheerio<any>,
|
|
||||||
options?: { pxPerLevel?: number },
|
|
||||||
): void {
|
|
||||||
const $els = $root.find('p, h1, h2, h3, h4, h5, h6');
|
|
||||||
|
|
||||||
const values: number[] = [];
|
|
||||||
$els.each((_, el) => {
|
|
||||||
const style = $(el).attr('style');
|
|
||||||
if (!style) return;
|
|
||||||
const match = MARGIN_LEFT_RE.exec(style);
|
|
||||||
if (!match) return;
|
|
||||||
const px = parseFloat(match[1]);
|
|
||||||
if (Number.isFinite(px) && px > 0) values.push(px);
|
|
||||||
});
|
|
||||||
if (values.length === 0) return;
|
|
||||||
|
|
||||||
const unit = options?.pxPerLevel ?? detectIndentUnit(values);
|
|
||||||
if (!unit || unit <= 0) return;
|
|
||||||
|
|
||||||
$els.each((_, el) => {
|
|
||||||
const $el = $(el);
|
|
||||||
const style = $el.attr('style');
|
|
||||||
if (!style) return;
|
|
||||||
const match = MARGIN_LEFT_RE.exec(style);
|
|
||||||
if (!match) return;
|
|
||||||
const px = parseFloat(match[1]);
|
|
||||||
if (!Number.isFinite(px) || px <= 0) return;
|
|
||||||
const level = Math.min(
|
|
||||||
MAX_INDENT_LEVEL,
|
|
||||||
Math.max(1, Math.round(px / unit)),
|
|
||||||
);
|
|
||||||
$el.attr('data-indent', String(level));
|
|
||||||
const remaining = style.replace(MARGIN_LEFT_STRIP_RE, '').trim();
|
|
||||||
if (remaining) {
|
|
||||||
$el.attr('style', remaining);
|
|
||||||
} else {
|
|
||||||
$el.removeAttr('style');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectIndentUnit(values: number[]): number {
|
|
||||||
// Confluence emits floats like "30.0"; round to ints for a clean GCD.
|
|
||||||
const ints = values.map((v) => Math.round(v)).filter((v) => v > 0);
|
|
||||||
if (ints.length === 0) return 0;
|
|
||||||
return ints.reduce((a, b) => gcd(a, b));
|
|
||||||
}
|
|
||||||
|
|
||||||
function gcd(a: number, b: number): number {
|
|
||||||
while (b !== 0) {
|
|
||||||
[a, b] = [b, a % b];
|
|
||||||
}
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ export enum FileImportSource {
|
|||||||
Generic = 'generic',
|
Generic = 'generic',
|
||||||
Notion = 'notion',
|
Notion = 'notion',
|
||||||
Confluence = 'confluence',
|
Confluence = 'confluence',
|
||||||
ConfluenceApi = 'confluence-api'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum FileTaskStatus {
|
export enum FileTaskStatus {
|
||||||
|
|||||||
@@ -97,9 +97,6 @@ export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import { applyConfluenceMarginLeftIndent } from './confluence-indent';
|
|
||||||
export { applyConfluenceMarginLeftIndent };
|
|
||||||
|
|
||||||
function isBareLink($el: Cheerio<any>): boolean {
|
function isBareLink($el: Cheerio<any>): boolean {
|
||||||
const href = $el.attr("href")?.trim();
|
const href = $el.attr("href")?.trim();
|
||||||
const text = $el.text().trim();
|
const text = $el.text().trim();
|
||||||
@@ -111,16 +108,14 @@ function isBareLink($el: Cheerio<any>): boolean {
|
|||||||
|
|
||||||
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||||
normalizeTableColumnWidths($, $root);
|
normalizeTableColumnWidths($, $root);
|
||||||
applyConfluenceMarginLeftIndent($, $root);
|
|
||||||
|
|
||||||
// Auto-embed only bare links (text equals href) that are the sole meaningful
|
|
||||||
// child of their parent block. Anything else stays an inline link.
|
|
||||||
$root.find('a[href]').each((_, el) => {
|
$root.find('a[href]').each((_, el) => {
|
||||||
const $el = $(el);
|
const $el = $(el);
|
||||||
const url = $el.attr('href')!;
|
const url = $el.attr('href')!;
|
||||||
const { provider } = getEmbedUrlAndProvider(url);
|
const { provider } = getEmbedUrlAndProvider(url);
|
||||||
if (provider === 'iframe' || !isBareLink($el)) return;
|
if (provider === 'iframe' || !isBareLink($el)) {
|
||||||
if (!isSoleMeaningfulChild($el, el)) return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const embed = `<div data-type=\"embed\" data-src=\"${url}\" data-provider=\"${provider}\" data-align=\"center\" data-width=\"640\" data-height=\"480\"></div>`;
|
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);
|
$el.replaceWith(embed);
|
||||||
@@ -136,21 +131,6 @@ export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSoleMeaningfulChild(
|
|
||||||
$el: Cheerio<any>,
|
|
||||||
rawEl: any,
|
|
||||||
): boolean {
|
|
||||||
const $parent = $el.parent();
|
|
||||||
if ($parent.length === 0) return true;
|
|
||||||
const others = $parent.contents().toArray().filter((n: any) => {
|
|
||||||
if (n === rawEl) return false;
|
|
||||||
if (n.type === 'text') return (n.data ?? '').trim() !== '';
|
|
||||||
if (n.type === 'tag' && n.name === 'br') return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return others.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLUMN_LAYOUTS = [
|
const COLUMN_LAYOUTS = [
|
||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ export enum QueueName {
|
|||||||
GENERAL_QUEUE = '{general-queue}',
|
GENERAL_QUEUE = '{general-queue}',
|
||||||
BILLING_QUEUE = '{billing-queue}',
|
BILLING_QUEUE = '{billing-queue}',
|
||||||
FILE_TASK_QUEUE = '{file-task-queue}',
|
FILE_TASK_QUEUE = '{file-task-queue}',
|
||||||
CONFLUENCE_IMPORT_QUEUE = '{confluence-import-queue}',
|
|
||||||
SEARCH_QUEUE = '{search-queue}',
|
SEARCH_QUEUE = '{search-queue}',
|
||||||
AI_QUEUE = '{ai-queue}',
|
AI_QUEUE = '{ai-queue}',
|
||||||
HISTORY_QUEUE = '{history-queue}',
|
HISTORY_QUEUE = '{history-queue}',
|
||||||
@@ -33,7 +32,6 @@ export enum QueueJob {
|
|||||||
FIRST_PAYMENT_EMAIL = 'first-payment-email',
|
FIRST_PAYMENT_EMAIL = 'first-payment-email',
|
||||||
|
|
||||||
IMPORT_TASK = 'import-task',
|
IMPORT_TASK = 'import-task',
|
||||||
CONFLUENCE_API_IMPORT = 'confluence-api-import-task',
|
|
||||||
EXPORT_TASK = 'export-task',
|
EXPORT_TASK = 'export-task',
|
||||||
|
|
||||||
SEARCH_INDEX_PAGE = 'search-index-page',
|
SEARCH_INDEX_PAGE = 'search-index-page',
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
|
||||||
import { createQueueRegistrations } from './queue.registrations';
|
|
||||||
|
|
||||||
// Global so @InjectQueue tokens resolve anywhere, in the app and in worker contexts.
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
imports: [...createQueueRegistrations()],
|
|
||||||
exports: [BullModule],
|
|
||||||
})
|
|
||||||
export class QueueProducersModule {}
|
|
||||||
@@ -1,18 +1,115 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
import { EnvironmentService } from '../environment/environment.service';
|
import { EnvironmentService } from '../environment/environment.service';
|
||||||
|
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
|
||||||
|
import { QueueName } from './constants';
|
||||||
import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
||||||
import { bullConfigFactory } from './queue.registrations';
|
|
||||||
import { QueueProducersModule } from './queue-producers.module';
|
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
BullModule.forRootAsync({
|
BullModule.forRootAsync({
|
||||||
useFactory: bullConfigFactory,
|
useFactory: (environmentService: EnvironmentService) => {
|
||||||
|
const redisConfig = parseRedisUrl(environmentService.getRedisUrl());
|
||||||
|
return {
|
||||||
|
connection: {
|
||||||
|
host: redisConfig.host,
|
||||||
|
port: redisConfig.port,
|
||||||
|
username: redisConfig.username,
|
||||||
|
password: redisConfig.password,
|
||||||
|
db: redisConfig.db,
|
||||||
|
family: redisConfig.family,
|
||||||
|
tls: redisConfig.tls,
|
||||||
|
retryStrategy: createRetryStrategy(),
|
||||||
|
},
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 20 * 1000,
|
||||||
|
},
|
||||||
|
removeOnComplete: {
|
||||||
|
count: 200,
|
||||||
|
},
|
||||||
|
removeOnFail: {
|
||||||
|
count: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
inject: [EnvironmentService],
|
inject: [EnvironmentService],
|
||||||
}),
|
}),
|
||||||
QueueProducersModule,
|
BullModule.registerQueue({
|
||||||
|
name: QueueName.EMAIL_QUEUE,
|
||||||
|
}),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: QueueName.ATTACHMENT_QUEUE,
|
||||||
|
}),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: QueueName.GENERAL_QUEUE,
|
||||||
|
}),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: QueueName.BILLING_QUEUE,
|
||||||
|
}),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: QueueName.FILE_TASK_QUEUE,
|
||||||
|
defaultJobOptions: {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
attempts: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: QueueName.SEARCH_QUEUE,
|
||||||
|
defaultJobOptions: {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
attempts: 2,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: QueueName.AI_QUEUE,
|
||||||
|
defaultJobOptions: {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
attempts: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: QueueName.HISTORY_QUEUE,
|
||||||
|
defaultJobOptions: {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
attempts: 2,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: QueueName.NOTIFICATION_QUEUE,
|
||||||
|
}),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: QueueName.AUDIT_QUEUE,
|
||||||
|
defaultJobOptions: {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
attempts: 3,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: QueueName.SIEM_QUEUE,
|
||||||
|
defaultJobOptions: {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
attempts: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: QueueName.BASE_QUEUE,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 2,
|
||||||
|
removeOnComplete: { count: 200 },
|
||||||
|
removeOnFail: { count: 100 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
exports: [BullModule],
|
exports: [BullModule],
|
||||||
providers: [GeneralQueueProcessor],
|
providers: [GeneralQueueProcessor],
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
import { BullModule } from '@nestjs/bullmq';
|
|
||||||
import { EnvironmentService } from '../environment/environment.service';
|
|
||||||
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
|
|
||||||
import { QueueName } from './constants';
|
|
||||||
|
|
||||||
export const bullConfigFactory = (environmentService: EnvironmentService) => {
|
|
||||||
const redisConfig = parseRedisUrl(environmentService.getRedisUrl());
|
|
||||||
return {
|
|
||||||
connection: {
|
|
||||||
host: redisConfig.host,
|
|
||||||
port: redisConfig.port,
|
|
||||||
username: redisConfig.username,
|
|
||||||
password: redisConfig.password,
|
|
||||||
db: redisConfig.db,
|
|
||||||
family: redisConfig.family,
|
|
||||||
tls: redisConfig.tls,
|
|
||||||
retryStrategy: createRetryStrategy(),
|
|
||||||
},
|
|
||||||
defaultJobOptions: {
|
|
||||||
attempts: 3,
|
|
||||||
backoff: {
|
|
||||||
type: 'exponential',
|
|
||||||
delay: 20 * 1000,
|
|
||||||
},
|
|
||||||
removeOnComplete: {
|
|
||||||
count: 200,
|
|
||||||
},
|
|
||||||
removeOnFail: {
|
|
||||||
count: 100,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createQueueRegistrations = () => [
|
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.EMAIL_QUEUE,
|
|
||||||
}),
|
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.ATTACHMENT_QUEUE,
|
|
||||||
}),
|
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.GENERAL_QUEUE,
|
|
||||||
}),
|
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.BILLING_QUEUE,
|
|
||||||
}),
|
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.FILE_TASK_QUEUE,
|
|
||||||
defaultJobOptions: {
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: true,
|
|
||||||
attempts: 1,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.SEARCH_QUEUE,
|
|
||||||
defaultJobOptions: {
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: true,
|
|
||||||
attempts: 2,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.AI_QUEUE,
|
|
||||||
defaultJobOptions: {
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: true,
|
|
||||||
attempts: 1,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.HISTORY_QUEUE,
|
|
||||||
defaultJobOptions: {
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: true,
|
|
||||||
attempts: 2,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.NOTIFICATION_QUEUE,
|
|
||||||
}),
|
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.AUDIT_QUEUE,
|
|
||||||
defaultJobOptions: {
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: true,
|
|
||||||
attempts: 3,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.SIEM_QUEUE,
|
|
||||||
defaultJobOptions: {
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: true,
|
|
||||||
attempts: 1,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.BASE_QUEUE,
|
|
||||||
defaultJobOptions: {
|
|
||||||
attempts: 2,
|
|
||||||
removeOnComplete: { count: 200 },
|
|
||||||
removeOnFail: { count: 100 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
@@ -54,8 +54,6 @@ export class StaticModule implements OnModuleInit {
|
|||||||
this.environmentService.getAiVectorDriver() === 'turbopuffer'
|
this.environmentService.getAiVectorDriver() === 'turbopuffer'
|
||||||
? 'turbopuffer'
|
? 'turbopuffer'
|
||||||
: undefined,
|
: undefined,
|
||||||
BETA_CONFLUENCE_IMPORTER:
|
|
||||||
this.environmentService.isBetaConfluenceImporter(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
|
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export * from "./lib/embed-provider";
|
|||||||
export * from "./lib/subpages";
|
export * from "./lib/subpages";
|
||||||
export * from "./lib/transclusion";
|
export * from "./lib/transclusion";
|
||||||
export * from "./lib/highlight";
|
export * from "./lib/highlight";
|
||||||
export * from "./lib/text-color";
|
|
||||||
export * from "./lib/indent";
|
export * from "./lib/indent";
|
||||||
export * from "./lib/heading/heading";
|
export * from "./lib/heading/heading";
|
||||||
export * from "./lib/unique-id";
|
export * from "./lib/unique-id";
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ export const Highlight = TiptapHighlight.extend<HighlightOptions>({
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --mark-bg lets CSS derive a legible dark-mode variant for
|
|
||||||
// arbitrary (imported) colors.
|
|
||||||
return {
|
return {
|
||||||
"data-color": attributes.color,
|
"data-color": attributes.color,
|
||||||
style: `background-color: ${attributes.color}; --mark-bg: ${attributes.color}; color: inherit`,
|
style: `background-color: ${attributes.color}; color: inherit`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const TableCell = TiptapTableCell.extend({
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
style: `background-color: ${attributes.backgroundColor}; --cell-bg: ${attributes.backgroundColor}`,
|
style: `background-color: ${attributes.backgroundColor}`,
|
||||||
"data-background-color": attributes.backgroundColor,
|
"data-background-color": attributes.backgroundColor,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const TableHeader = TiptapTableHeader.extend({
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
style: `background-color: ${attributes.backgroundColor}; --cell-bg: ${attributes.backgroundColor}`,
|
style: `background-color: ${attributes.backgroundColor}`,
|
||||||
"data-background-color": attributes.backgroundColor,
|
"data-background-color": attributes.backgroundColor,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { getStyleProperty } from "@tiptap/core";
|
|
||||||
import { Color as TiptapColor } from "@tiptap/extension-color";
|
|
||||||
|
|
||||||
export const Color = TiptapColor.extend({
|
|
||||||
addGlobalAttributes() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
types: this.options.types,
|
|
||||||
attributes: {
|
|
||||||
color: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (element) => {
|
|
||||||
const value =
|
|
||||||
element.getAttribute("data-text-color") ??
|
|
||||||
getStyleProperty(element, "color") ??
|
|
||||||
element.style.color;
|
|
||||||
return value?.replace(/['"]+/g, "") || null;
|
|
||||||
},
|
|
||||||
renderHTML: (attributes) => {
|
|
||||||
if (!attributes.color) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
// --text-color lets CSS derive a legible dark-mode variant for
|
|
||||||
// arbitrary (imported) colors.
|
|
||||||
return {
|
|
||||||
"data-text-color": attributes.color,
|
|
||||||
style: `color: ${attributes.color}; --text-color: ${attributes.color}`,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Generated
-3
@@ -639,9 +639,6 @@ importers:
|
|||||||
csv-stringify:
|
csv-stringify:
|
||||||
specifier: 6.8.0
|
specifier: 6.8.0
|
||||||
version: 6.8.0
|
version: 6.8.0
|
||||||
entities:
|
|
||||||
specifier: 7.0.1
|
|
||||||
version: 7.0.1
|
|
||||||
fast-bm25:
|
fast-bm25:
|
||||||
specifier: 0.0.5
|
specifier: 0.0.5
|
||||||
version: 0.0.5(typescript@5.9.3)
|
version: 0.0.5(typescript@5.9.3)
|
||||||
|
|||||||
Reference in New Issue
Block a user