Compare commits

..
Author SHA1 Message Date
Philipinho 23cddb78b7 cleanup 2026-09-08 13:24:21 +01:00
Philipinho f05a611910 fix: h1 heading weight in editor 2026-09-08 13:20:40 +01:00
Philip Okugbe 5792fc7ca2 fix: db lock operations (#2479)
* fix: advisory lock for page move

* fix: lock role count check
2026-09-08 13:16:56 +01:00
Salihu 949072744d fix: align input shortcuts (#2467)
* align input shortcuts

* minor fix

* minor fix
2026-09-08 01:54:41 +01:00
Philip Okugbe 0a87db4f1f fix: meta title for shared subpages (#2478) 2026-09-07 16:43:02 +01:00
74 changed files with 675 additions and 2222 deletions
@@ -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",
+1 -7
View File
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx"; import { 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[];
};
-1
View File
@@ -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 {
-4
View File
@@ -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,
-2
View File
@@ -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),
-1
View File
@@ -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",
-2
View File
@@ -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,
-1
View File
@@ -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 ');
+15 -6
View File
@@ -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;
} }
@@ -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>`;
-1
View File
@@ -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";
+1 -3
View File
@@ -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`,
}; };
}, },
}, },
+1 -1
View File
@@ -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,
}; };
}, },
+1 -1
View File
@@ -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,
}; };
}, },
-35
View File
@@ -1,35 +0,0 @@
import { getStyleProperty } from "@tiptap/core";
import { Color as TiptapColor } from "@tiptap/extension-color";
export const Color = TiptapColor.extend({
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
color: {
default: null,
parseHTML: (element) => {
const value =
element.getAttribute("data-text-color") ??
getStyleProperty(element, "color") ??
element.style.color;
return value?.replace(/['"]+/g, "") || null;
},
renderHTML: (attributes) => {
if (!attributes.color) {
return {};
}
// --text-color lets CSS derive a legible dark-mode variant for
// arbitrary (imported) colors.
return {
"data-text-color": attributes.color,
style: `color: ${attributes.color}; --text-color: ${attributes.color}`,
};
},
},
},
},
];
},
});
-3
View File
@@ -639,9 +639,6 @@ importers:
csv-stringify: 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)