mirror of
https://github.com/docmost/docmost.git
synced 2025-11-10 18:42:06 +10:00
Compare commits
36 Commits
enhance-sh
...
upgrade/ex
| Author | SHA1 | Date | |
|---|---|---|---|
| 527a6a650f | |||
| ef261565aa | |||
| 698cef55ea | |||
| 50b3f9ddd9 | |||
| 0029f84d50 | |||
| 6d024fc3de | |||
| ce1503af85 | |||
| 69447fc375 | |||
| 858ff9da06 | |||
| 343b2976c2 | |||
| 7491224d0f | |||
| 4a0b4040ed | |||
| e3ba817723 | |||
| b0491d5da4 | |||
| 1c200dbd0f | |||
| fb7e4a7956 | |||
| 1413033568 | |||
| 00f4588c21 | |||
| 3a75251e75 | |||
| c6bca6a602 | |||
| 55d1a2c932 | |||
| bc3cb2d63f | |||
| 7adbf85030 | |||
| de7982fe30 | |||
| 0402f7efb5 | |||
| 8327251ab6 | |||
| e8847bd9cd | |||
| 9bbd62e0f0 | |||
| 0289c5cb09 | |||
| 7993532111 | |||
| 31e5c0c660 | |||
| 33c314d4e8 | |||
| 08f223899a | |||
| c528f7e858 | |||
| c26a851d52 | |||
| 30dabd9fc1 |
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.20.0",
|
||||
"version": "0.20.4",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@ -15,7 +15,7 @@
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@excalidraw/excalidraw": "^0.17.6",
|
||||
"@excalidraw/excalidraw": "0.18.0-864353b",
|
||||
"@mantine/core": "^7.17.0",
|
||||
"@mantine/form": "^7.17.0",
|
||||
"@mantine/hooks": "^7.17.0",
|
||||
@ -29,6 +29,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlightjs-sap-abap": "^0.3.0",
|
||||
"i18next": "^23.14.0",
|
||||
"i18next-http-backend": "^2.6.1",
|
||||
"jotai": "^2.12.1",
|
||||
@ -41,7 +42,7 @@
|
||||
"mitt": "^3.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "3.4.0",
|
||||
"react-clear-modal": "^2.0.11",
|
||||
"react-clear-modal": "^2.0.15",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-drawio": "^1.0.1",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
@ -76,6 +77,6 @@
|
||||
"prettier": "^3.4.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.17.0",
|
||||
"vite": "^6.3.2"
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@ -383,5 +383,8 @@
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Publicly shared pages from spaces you are a member of will appear here",
|
||||
"Share deleted successfully": "Share deleted successfully",
|
||||
"Share not found": "Share not found",
|
||||
"Failed to share page": "Failed to share page"
|
||||
"Failed to share page": "Failed to share page",
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"Page copied successfully": "Page copied successfully"
|
||||
}
|
||||
|
||||
20
apps/client/src/components/icons/confluence-icon.tsx
Normal file
20
apps/client/src/components/icons/confluence-icon.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { rem } from "@mantine/core";
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
export function ConfluenceIcon({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="M.87 18.257c-.248.382-.53.875-.763 1.245a.764.764 0 0 0 .255 1.04l4.965 3.054a.764.764 0 0 0 1.058-.26c.199-.332.454-.763.733-1.221 1.967-3.247 3.945-2.853 7.508-1.146l4.957 2.337a.764.764 0 0 0 1.028-.382l2.364-5.346a.764.764 0 0 0-.382-1 599.851 599.851 0 0 1-4.965-2.361C10.911 10.97 5.224 11.185.87 18.257zM23.131 5.743c.249-.405.531-.875.764-1.25a.764.764 0 0 0-.256-1.034L18.675.404a.764.764 0 0 0-1.058.26c-.195.335-.451.763-.734 1.225-1.966 3.246-3.945 2.85-7.508 1.146L4.437.694a.764.764 0 0 0-1.027.382L1.046 6.422a.764.764 0 0 0 .382 1c1.039.49 3.105 1.467 4.965 2.361 6.698 3.246 12.392 3.029 16.738-4.04z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -14,7 +14,6 @@ import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||
import Aside from "@/components/layouts/global/aside.tsx";
|
||||
import classes from "./app-shell.module.css";
|
||||
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
|
||||
import { useClickOutside, useMergedRef } from "@mantine/hooks";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
|
||||
export default function GlobalAppShell({
|
||||
@ -30,13 +29,6 @@ export default function GlobalAppShell({
|
||||
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const sidebarRef = useRef(null);
|
||||
const navbarOutsideRef = useClickOutside(() => {
|
||||
if (mobileOpened) {
|
||||
toggleMobile();
|
||||
}
|
||||
});
|
||||
|
||||
const mergedRef = useMergedRef(sidebarRef, navbarOutsideRef);
|
||||
|
||||
const startResizing = React.useCallback((mouseDownEvent) => {
|
||||
mouseDownEvent.preventDefault();
|
||||
@ -112,7 +104,7 @@ export default function GlobalAppShell({
|
||||
<AppShell.Navbar
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
ref={mergedRef}
|
||||
ref={sidebarRef}
|
||||
>
|
||||
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||
{isSpaceRoute && <SpaceSidebar />}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Alert } from "@mantine/core";
|
||||
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
|
||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
||||
import { getBillingTrialDays } from '@/lib/config.ts';
|
||||
|
||||
export default function BillingTrial() {
|
||||
const { data: billing, isLoading } = useBillingQuery();
|
||||
@ -15,14 +16,14 @@ export default function BillingTrial() {
|
||||
{trialDaysLeft > 0 && !billing && (
|
||||
<Alert title="Your Trial is Active 🎉" color="blue" radius="md">
|
||||
You have {trialDaysLeft} {trialDaysLeft === 1 ? "day" : "days"} left
|
||||
in your 7-day trial. Please subscribe to a plan before your trial
|
||||
in your {getBillingTrialDays()}-day free trial. Please subscribe to a paid plan before your trial
|
||||
ends.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{trialDaysLeft === 0 && (
|
||||
<Alert title="Your Trial has ended" color="red" radius="md">
|
||||
Your 7-day trial has come to an end. Please subscribe to a plan to
|
||||
Your {getBillingTrialDays()}-day free trial has come to an end. Please subscribe to a paid plan to
|
||||
continue using this service.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export enum BillingPlan {
|
||||
STANDARD = "standard",
|
||||
BUSINESS = "business",
|
||||
}
|
||||
|
||||
export interface IBilling {
|
||||
|
||||
@ -2,14 +2,18 @@ import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { BillingPlan } from "@/ee/billing/types/billing.types.ts";
|
||||
|
||||
export const usePlan = () => {
|
||||
const usePlan = () => {
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
|
||||
const isStandard =
|
||||
typeof workspace?.plan === "string" &&
|
||||
workspace?.plan.toLowerCase() === BillingPlan.STANDARD.toLowerCase();
|
||||
|
||||
return { isStandard };
|
||||
const isBusiness =
|
||||
typeof workspace?.plan === "string" &&
|
||||
workspace?.plan.toLowerCase() === BillingPlan.BUSINESS.toLowerCase();
|
||||
|
||||
return { isStandard, isBusiness };
|
||||
};
|
||||
|
||||
export default usePlan;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { getBillingTrialDays, isCloud } from "@/lib/config.ts";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
@ -18,7 +18,7 @@ export const useTrialEndAction = () => {
|
||||
notifications.show({
|
||||
position: "top-right",
|
||||
color: "red",
|
||||
title: "Your 7-day trial has ended",
|
||||
title: `Your ${getBillingTrialDays()}-day trial has ended`,
|
||||
message:
|
||||
"Please upgrade to a paid plan or contact your workspace admin.",
|
||||
autoClose: false,
|
||||
|
||||
@ -15,7 +15,7 @@ export default function EnforceSso() {
|
||||
<Text size="md">{t("Enforce SSO")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Once enforced, members will not able able to login with email and password.",
|
||||
"Once enforced, members will not be able to login with email and password.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@ -10,11 +10,13 @@ import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
|
||||
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||
import usePlan from "@/ee/hooks/use-plan.tsx";
|
||||
|
||||
export default function Security() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
const { hasLicenseKey } = useLicense();
|
||||
const { isBusiness } = usePlan();
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
@ -35,8 +37,7 @@ export default function Security() {
|
||||
Single sign-on (SSO)
|
||||
</Title>
|
||||
|
||||
{/*TODO: revisit when we add a second plan */}
|
||||
{!isCloud() && hasLicenseKey ? (
|
||||
{(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
|
||||
<>
|
||||
<EnforceSso />
|
||||
<Divider my="lg" />
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { Button, Group, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type CommentActionsProps = {
|
||||
@ -15,7 +15,7 @@ function CommentActions({
|
||||
isCommentEditor,
|
||||
}: CommentActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
return (
|
||||
<Group justify="flex-end" pt="sm" wrap="nowrap">
|
||||
{isCommentEditor && (
|
||||
|
||||
@ -15,6 +15,7 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
|
||||
interface CommentDialogProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
@ -35,6 +36,8 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const { isPending } = createCommentMutation;
|
||||
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setShowCommentPopup(false);
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
@ -63,11 +66,23 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
.run();
|
||||
setActiveCommentId(createdComment.id);
|
||||
|
||||
//unselect text to close bubble menu
|
||||
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
|
||||
|
||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||
setTimeout(() => {
|
||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView();
|
||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.scrollIntoView()
|
||||
);
|
||||
}, 400);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} finally {
|
||||
setShowCommentPopup(false);
|
||||
@ -109,6 +124,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
|
||||
<CommentEditor
|
||||
onUpdate={handleCommentEditorChange}
|
||||
onSave={handleAddComment}
|
||||
placeholder={t("Write a comment")}
|
||||
editable={true}
|
||||
autofocus={true}
|
||||
|
||||
@ -8,10 +8,12 @@ import { useFocusWithin } from "@mantine/hooks";
|
||||
import clsx from "clsx";
|
||||
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmojiCommand from "@/features/editor/extensions/emoji-command";
|
||||
|
||||
interface CommentEditorProps {
|
||||
defaultContent?: any;
|
||||
onUpdate?: any;
|
||||
onSave?: any;
|
||||
editable: boolean;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
@ -22,6 +24,7 @@ const CommentEditor = forwardRef(
|
||||
{
|
||||
defaultContent,
|
||||
onUpdate,
|
||||
onSave,
|
||||
editable,
|
||||
placeholder,
|
||||
autofocus,
|
||||
@ -42,7 +45,35 @@ const CommentEditor = forwardRef(
|
||||
}),
|
||||
Underline,
|
||||
Link,
|
||||
EmojiCommand,
|
||||
],
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (
|
||||
[
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"Enter",
|
||||
].includes(event.key)
|
||||
) {
|
||||
const emojiCommand = document.querySelector("#emoji-command");
|
||||
if (emojiCommand) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (onSave) onSave();
|
||||
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
if (onUpdate) onUpdate(editor.getJSON());
|
||||
},
|
||||
@ -53,6 +84,10 @@ const CommentEditor = forwardRef(
|
||||
autofocus: (autofocus && "end") || false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
commentEditor.commands.setContent(defaultContent);
|
||||
}, [defaultContent]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (autofocus) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Group, Text, Box } from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { timeAgo } from "@/lib/time";
|
||||
@ -15,12 +15,14 @@ import {
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
|
||||
interface CommentListItemProps {
|
||||
comment: IComment;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
function CommentListItem({ comment }: CommentListItemProps) {
|
||||
function CommentListItem({ comment, pageId }: CommentListItemProps) {
|
||||
const { hovered, ref } = useHover();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@ -29,6 +31,11 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
const updateCommentMutation = useUpdateCommentMutation();
|
||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const emit = useQueryEmit();
|
||||
|
||||
useEffect(() => {
|
||||
setContent(comment.content)
|
||||
}, [comment]);
|
||||
|
||||
async function handleUpdateComment() {
|
||||
try {
|
||||
@ -39,6 +46,11 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
};
|
||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||
setIsEditing(false);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update comment:", error);
|
||||
} finally {
|
||||
@ -50,11 +62,27 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
try {
|
||||
await deleteCommentMutation.mutateAsync(comment.id);
|
||||
editor?.commands.unsetComment(comment.id);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: pageId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete comment:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommentClick(comment: IComment) {
|
||||
const el = document.querySelector(`.comment-mark[data-comment-id="${comment.id}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el.classList.add("comment-highlight");
|
||||
setTimeout(() => {
|
||||
el.classList.remove("comment-highlight");
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditToggle() {
|
||||
setIsEditing(true);
|
||||
}
|
||||
@ -99,7 +127,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
|
||||
<div>
|
||||
{!comment.parentCommentId && comment?.selection && (
|
||||
<Box className={classes.textSelection}>
|
||||
<Box className={classes.textSelection} onClick={() => handleCommentClick(comment)}>
|
||||
<Text size="sm">{comment?.selection}</Text>
|
||||
</Box>
|
||||
)}
|
||||
@ -112,6 +140,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
||||
defaultContent={content}
|
||||
editable={true}
|
||||
onUpdate={(newContent: any) => setContent(newContent)}
|
||||
onSave={handleUpdateComment}
|
||||
autofocus={true}
|
||||
/>
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
|
||||
function CommentList() {
|
||||
const { t } = useTranslation();
|
||||
@ -26,6 +27,7 @@ function CommentList() {
|
||||
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const handleAddReply = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
@ -38,6 +40,11 @@ function CommentList() {
|
||||
};
|
||||
|
||||
await createCommentMutation.mutateAsync(commentData);
|
||||
|
||||
emit({
|
||||
operation: "invalidateComment",
|
||||
pageId: page?.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to post comment:", error);
|
||||
} finally {
|
||||
@ -59,8 +66,8 @@ function CommentList() {
|
||||
data-comment-id={comment.id}
|
||||
>
|
||||
<div>
|
||||
<CommentListItem comment={comment} />
|
||||
<MemoizedChildComments comments={comments} parentId={comment.id} />
|
||||
<CommentListItem comment={comment} pageId={page?.id} />
|
||||
<MemoizedChildComments comments={comments} parentId={comment.id} pageId={page?.id} />
|
||||
</div>
|
||||
|
||||
<Divider my={4} />
|
||||
@ -99,8 +106,9 @@ function CommentList() {
|
||||
interface ChildCommentsProps {
|
||||
comments: IPagination<IComment>;
|
||||
parentId: string;
|
||||
pageId: string;
|
||||
}
|
||||
const ChildComments = ({ comments, parentId }: ChildCommentsProps) => {
|
||||
const ChildComments = ({ comments, parentId, pageId }: ChildCommentsProps) => {
|
||||
const getChildComments = useCallback(
|
||||
(parentId: string) =>
|
||||
comments.items.filter(
|
||||
@ -113,10 +121,11 @@ const ChildComments = ({ comments, parentId }: ChildCommentsProps) => {
|
||||
<div>
|
||||
{getChildComments(parentId).map((childComment) => (
|
||||
<div key={childComment.id}>
|
||||
<CommentListItem comment={childComment} />
|
||||
<CommentListItem comment={childComment} pageId={pageId} />
|
||||
<MemoizedChildComments
|
||||
comments={comments}
|
||||
parentId={childComment.id}
|
||||
pageId={pageId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@ -142,6 +151,7 @@ const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
|
||||
<CommentEditor
|
||||
ref={commentEditorRef}
|
||||
onUpdate={setContent}
|
||||
onSave={handleSave}
|
||||
editable={true}
|
||||
/>
|
||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
||||
|
||||
@ -11,22 +11,25 @@
|
||||
border-left: 2px solid var(--mantine-color-gray-6);
|
||||
padding: 8px;
|
||||
background: var(--mantine-color-gray-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.commentEditor {
|
||||
|
||||
.focused {
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
.ProseMirror :global(.ProseMirror){
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 20vh;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
margin-top: 2px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
|
||||
@ -15,13 +15,13 @@ import {
|
||||
import { IconEdit } from "@tabler/icons-react";
|
||||
import { z } from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import {
|
||||
getEmbedProviderById,
|
||||
getEmbedUrlAndProvider,
|
||||
} from "@/features/editor/components/embed/providers.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
import {
|
||||
getEmbedProviderById,
|
||||
getEmbedUrlAndProvider,
|
||||
} from "@docmost/editor-ext";
|
||||
|
||||
const schema = z.object({
|
||||
url: z
|
||||
@ -52,6 +52,10 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
async function onSubmit(data: { url: string }) {
|
||||
if (provider) {
|
||||
const embedProvider = getEmbedProviderById(provider);
|
||||
if (embedProvider.id === "iframe") {
|
||||
updateAttributes({ src: data.url });
|
||||
return;
|
||||
}
|
||||
if (embedProvider.regex.test(data.url)) {
|
||||
updateAttributes({ src: data.url });
|
||||
} else {
|
||||
@ -101,7 +105,7 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
|
||||
<Text component="span" size="lg" c="dimmed">
|
||||
{t("Embed {{provider}}", {
|
||||
provider: getEmbedProviderById(provider).name,
|
||||
provider: getEmbedProviderById(provider)?.name,
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@ -33,7 +33,7 @@ const renderEmojiItems = () => {
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
placement: "bottom",
|
||||
});
|
||||
},
|
||||
onStart: (props: {
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
type LibraryItems = any;
|
||||
|
||||
type LibraryPersistedData = {
|
||||
libraryItems: LibraryItems;
|
||||
};
|
||||
|
||||
export interface LibraryPersistenceAdapter {
|
||||
load(metadata: { source: "load" | "save" }):
|
||||
| Promise<{ libraryItems: LibraryItems } | null>
|
||||
| {
|
||||
libraryItems: LibraryItems;
|
||||
}
|
||||
| null;
|
||||
|
||||
save(libraryData: LibraryPersistedData): Promise<void> | void;
|
||||
}
|
||||
|
||||
const LOCAL_STORAGE_KEY = "excalidrawLibrary";
|
||||
|
||||
export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
|
||||
async load() {
|
||||
try {
|
||||
const data = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error downloading Excalidraw library from localStorage", e);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async save(libraryData) {
|
||||
try {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(libraryData));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"Error while saving library from Excalidraw to localStorage",
|
||||
e,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -13,7 +13,8 @@ import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { svgStringToFile } from "@/lib";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||
import { IAttachment } from "@/lib/types";
|
||||
import ReactClearModal from "react-clear-modal";
|
||||
import clsx from "clsx";
|
||||
@ -21,6 +22,8 @@ import { IconEdit } from "@tabler/icons-react";
|
||||
import { lazy } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||
|
||||
const Excalidraw = lazy(() =>
|
||||
import("@excalidraw/excalidraw").then((module) => ({
|
||||
@ -35,6 +38,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
|
||||
const [excalidrawAPI, setExcalidrawAPI] =
|
||||
useState<ExcalidrawImperativeAPI>(null);
|
||||
useHandleLibrary({
|
||||
excalidrawAPI,
|
||||
adapter: localStorageLibraryAdapter,
|
||||
});
|
||||
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
|
||||
@ -3,6 +3,7 @@ import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@ -18,7 +19,7 @@ import {
|
||||
import clsx from "clsx";
|
||||
import classes from "./mention.module.css";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { IconFileDescription, IconPlus } from "@tabler/icons-react";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
@ -28,14 +29,28 @@ import {
|
||||
MentionListProps,
|
||||
MentionSuggestionItem,
|
||||
} from "@/features/editor/components/mention/mention.type.ts";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { useCreatePageMutation, usePageQuery } from "@/features/page/queries/page-query";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
import { SimpleTree } from "react-arborist";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
|
||||
const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(1);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const { spaceSlug } = useParams();
|
||||
const { pageSlug, spaceSlug } = useParams();
|
||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: space } = useSpaceQuery(spaceSlug);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
||||
query: props.query,
|
||||
@ -45,12 +60,23 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const createPageItem = (label: string) : MentionSuggestionItem => {
|
||||
return {
|
||||
id: null,
|
||||
label: label,
|
||||
entityType: "page",
|
||||
entityId: null,
|
||||
slugId: null,
|
||||
icon: null,
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (suggestion && !isLoading) {
|
||||
let items: MentionSuggestionItem[] = [];
|
||||
|
||||
if (suggestion?.users?.length > 0) {
|
||||
items.push({ entityType: "header", label: "Users" });
|
||||
items.push({ entityType: "header", label: t("Users") });
|
||||
|
||||
items = items.concat(
|
||||
suggestion.users.map((user) => ({
|
||||
@ -64,7 +90,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
}
|
||||
|
||||
if (suggestion?.pages?.length > 0) {
|
||||
items.push({ entityType: "header", label: "Pages" });
|
||||
items.push({ entityType: "header", label: t("Pages") });
|
||||
items = items.concat(
|
||||
suggestion.pages.map((page) => ({
|
||||
id: uuid7(),
|
||||
@ -76,6 +102,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
})),
|
||||
);
|
||||
}
|
||||
items.push(createPageItem(props.query));
|
||||
|
||||
setRenderItems(items);
|
||||
// update editor storage
|
||||
@ -96,7 +123,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
creatorId: currentUser?.user.id,
|
||||
});
|
||||
}
|
||||
if (item.entityType === "page") {
|
||||
if (item.entityType === "page" && item.id!==null) {
|
||||
props.command({
|
||||
id: item.id,
|
||||
label: item.label || "Untitled",
|
||||
@ -106,6 +133,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
creatorId: currentUser?.user.id,
|
||||
});
|
||||
}
|
||||
if (item.entityType === "page" && item.id===null) {
|
||||
createPage(item.label);
|
||||
}
|
||||
}
|
||||
},
|
||||
[renderItems],
|
||||
@ -167,6 +197,58 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
},
|
||||
}));
|
||||
|
||||
const createPage = async (title: string) => {
|
||||
const payload: { spaceId: string; parentPageId?: string; title: string } = {
|
||||
spaceId: space.id,
|
||||
parentPageId: page.id || null,
|
||||
title: title
|
||||
};
|
||||
|
||||
let createdPage: IPage;
|
||||
try {
|
||||
createdPage = await createPageMutation.mutateAsync(payload);
|
||||
const parentId = page.id || null;
|
||||
const data = {
|
||||
id: createdPage.id,
|
||||
slugId: createdPage.slugId,
|
||||
name: createdPage.title,
|
||||
position: createdPage.position,
|
||||
spaceId: createdPage.spaceId,
|
||||
parentPageId: createdPage.parentPageId,
|
||||
children: [],
|
||||
} as any;
|
||||
|
||||
const lastIndex = tree.data.length;
|
||||
|
||||
tree.create({ parentId, index: lastIndex, data });
|
||||
setData(tree.data);
|
||||
|
||||
props.command({
|
||||
id: uuid7(),
|
||||
label: createdPage.title || "Untitled",
|
||||
entityType: "page",
|
||||
entityId: createdPage.id,
|
||||
slugId: createdPage.slugId,
|
||||
creatorId: currentUser?.user.id,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "addTreeNode",
|
||||
spaceId: space.id,
|
||||
payload: {
|
||||
parentId,
|
||||
index: lastIndex,
|
||||
data,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
|
||||
} catch (err) {
|
||||
throw new Error("Failed to create page");
|
||||
}
|
||||
}
|
||||
|
||||
// if no results and enter what to do?
|
||||
|
||||
useEffect(() => {
|
||||
@ -178,7 +260,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
if (renderItems.length === 0) {
|
||||
return (
|
||||
<Paper shadow="md" p="xs" withBorder>
|
||||
No results
|
||||
{ t("No results") }
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@ -248,14 +330,14 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> }
|
||||
</ActionIcon>
|
||||
)}
|
||||
</ActionIcon>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{item.label}
|
||||
{ (item.id) ? item.label : t("Create page") + ': ' + item.label }
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
@ -17,8 +17,8 @@ import {
|
||||
IconTable,
|
||||
IconTypography,
|
||||
IconMenu4,
|
||||
IconCalendar,
|
||||
} from "@tabler/icons-react";
|
||||
IconCalendar, IconAppWindow,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
CommandProps,
|
||||
SlashMenuGroupedItemsType,
|
||||
@ -357,6 +357,20 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Iframe embed",
|
||||
description: "Embed any Iframe",
|
||||
searchTerms: ["iframe"],
|
||||
icon: IconAppWindow,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setEmbed({ provider: "iframe" })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Airtable",
|
||||
description: "Embed Airtable",
|
||||
|
||||
@ -17,9 +17,9 @@ import {
|
||||
IconColumnRemove,
|
||||
IconRowInsertBottom,
|
||||
IconRowInsertTop,
|
||||
IconRowRemove,
|
||||
IconRowRemove, IconTableColumn, IconTableRow,
|
||||
IconTrashX,
|
||||
} from "@tabler/icons-react";
|
||||
} from '@tabler/icons-react';
|
||||
import { isCellSelection } from "@docmost/editor-ext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -50,6 +50,14 @@ export const TableMenu = React.memo(
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
}, [editor]);
|
||||
|
||||
const toggleHeaderColumn = useCallback(() => {
|
||||
editor.chain().focus().toggleHeaderColumn().run();
|
||||
}, [editor]);
|
||||
|
||||
const toggleHeaderRow = useCallback(() => {
|
||||
editor.chain().focus().toggleHeaderRow().run();
|
||||
}, [editor]);
|
||||
|
||||
const addColumnLeft = useCallback(() => {
|
||||
editor.chain().focus().addColumnBefore().run();
|
||||
}, [editor]);
|
||||
@ -180,6 +188,30 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header row")}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderRow}
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("Toggle header row")}
|
||||
>
|
||||
<IconTableRow size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header column")}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderColumn}
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("Toggle header column")}
|
||||
>
|
||||
<IconTableColumn size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete table")}>
|
||||
<ActionIcon
|
||||
onClick={deleteTable}
|
||||
|
||||
@ -58,6 +58,7 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
|
||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
import abap from "highlightjs-sap-abap";
|
||||
import elixir from "highlight.js/lib/languages/elixir";
|
||||
import erlang from "highlight.js/lib/languages/erlang";
|
||||
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
||||
@ -76,7 +77,7 @@ import { CharacterCount } from "@tiptap/extension-character-count";
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
lowlight.register("powershell", powershell);
|
||||
lowlight.register("powershell", powershell);
|
||||
lowlight.register("abap", abap);
|
||||
lowlight.register("erlang", erlang);
|
||||
lowlight.register("elixir", elixir);
|
||||
lowlight.register("dockerfile", dockerfile);
|
||||
|
||||
@ -219,9 +219,12 @@ export default function PageEditor({
|
||||
setActiveCommentId(commentId);
|
||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||
|
||||
const selector = `div[data-comment-id="${commentId}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView();
|
||||
//wait if aside is closed
|
||||
setTimeout(() => {
|
||||
const selector = `div[data-comment-id="${commentId}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 400);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -144,6 +144,19 @@
|
||||
border-bottom: 2px solid rgb(166, 158, 12);
|
||||
}
|
||||
|
||||
.comment-highlight {
|
||||
animation: flash-highlight 3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes flash-highlight {
|
||||
0% {
|
||||
background-color: #ff4d4d;
|
||||
}
|
||||
100% {
|
||||
background-color: rgba(255, 215, 0, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
.resize-cursor {
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
|
||||
@ -47,7 +47,7 @@
|
||||
|
||||
.column-resize-handle {
|
||||
background-color: #adf;
|
||||
bottom: -2px;
|
||||
bottom: -1px;
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
pointer-events: none;
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { useUpdatePageMutation } from "@/features/page/queries/page-query";
|
||||
import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import { useAtom } from "jotai";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
@ -38,7 +38,7 @@ export function TitleEditor({
|
||||
editable,
|
||||
}: TitleEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutateAsync: updatePageMutationAsync } = useUpdatePageMutation();
|
||||
const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||
const emit = useQueryEmit();
|
||||
@ -94,7 +94,7 @@ export function TitleEditor({
|
||||
return;
|
||||
}
|
||||
|
||||
updatePageMutationAsync({
|
||||
updateTitlePageMutationAsync({
|
||||
pageId: pageId,
|
||||
title: titleEditor.getText(),
|
||||
}).then((page) => {
|
||||
@ -103,9 +103,13 @@ export function TitleEditor({
|
||||
spaceId: page.spaceId,
|
||||
entity: ["pages"],
|
||||
id: page.id,
|
||||
payload: { title: page.title, slugId: page.slugId },
|
||||
payload: { title: page.title, slugId: page.slugId, parentPageId: page.parentPageId, icon: page.icon },
|
||||
};
|
||||
|
||||
if (page.title !== titleEditor.getText()) return;
|
||||
|
||||
updatePageData(page);
|
||||
|
||||
localEmitter.emit("message", event);
|
||||
emit(event);
|
||||
});
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { IFileTask } from "@/features/file-task/types/file-task.types.ts";
|
||||
|
||||
export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
|
||||
const req = await api.post<IFileTask>("/file-tasks/info", {
|
||||
fileTaskId: fileTaskId,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getFileTasks(): Promise<IFileTask[]> {
|
||||
const req = await api.post<IFileTask[]>("/file-tasks");
|
||||
return req.data;
|
||||
}
|
||||
17
apps/client/src/features/file-task/types/file-task.types.ts
Normal file
17
apps/client/src/features/file-task/types/file-task.types.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export interface IFileTask {
|
||||
id: string;
|
||||
type: "import" | "export";
|
||||
source: string;
|
||||
status: string;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
fileSize: number;
|
||||
fileExt: string;
|
||||
errorMessage: string | null;
|
||||
creatorId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
105
apps/client/src/features/page/components/copy-page-modal.tsx
Normal file
105
apps/client/src/features/page/components/copy-page-modal.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { Modal, Button, Group, Text } from "@mantine/core";
|
||||
import { copyPageToSpace } from "@/features/page/services/page-service.ts";
|
||||
import { useState } from "react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { SpaceSelect } from "@/features/space/components/sidebar/space-select.tsx";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
|
||||
interface CopyPageModalProps {
|
||||
pageId: string;
|
||||
currentSpaceSlug: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CopyPageModal({
|
||||
pageId,
|
||||
currentSpaceSlug,
|
||||
open,
|
||||
onClose,
|
||||
}: CopyPageModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [targetSpace, setTargetSpace] = useState<ISpace>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!targetSpace) return;
|
||||
|
||||
try {
|
||||
const copiedPage = await copyPageToSpace({
|
||||
pageId,
|
||||
spaceId: targetSpace.id,
|
||||
});
|
||||
queryClient.removeQueries({
|
||||
predicate: (item) =>
|
||||
["pages", "sidebar-pages", "root-sidebar-pages"].includes(
|
||||
item.queryKey[0] as string,
|
||||
),
|
||||
});
|
||||
|
||||
const pageUrl = buildPageUrl(
|
||||
copiedPage.space.slug,
|
||||
copiedPage.slugId,
|
||||
copiedPage.title,
|
||||
);
|
||||
navigate(pageUrl);
|
||||
notifications.show({
|
||||
message: t("Page copied successfully"),
|
||||
});
|
||||
onClose();
|
||||
setTargetSpace(null);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err.response?.data.message || "An error occurred",
|
||||
color: "red",
|
||||
});
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (space: ISpace) => {
|
||||
setTargetSpace(space);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
opened={open}
|
||||
onClose={onClose}
|
||||
size={500}
|
||||
padding="xl"
|
||||
yOffset="10vh"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>{t("Copy page")}</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Text mb="xs" c="dimmed" size="sm">
|
||||
{t("Copy page to a different space.")}
|
||||
</Text>
|
||||
|
||||
<SpaceSelect
|
||||
value={currentSpaceSlug}
|
||||
clearable={false}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Group justify="end" mt="md">
|
||||
<Button onClick={onClose} variant="default">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCopy}>{t("Copy")}</Button>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
@ -12,7 +12,7 @@ import {
|
||||
IconTrash,
|
||||
IconWifiOff,
|
||||
} from "@tabler/icons-react";
|
||||
import React, { useEffect } from "react";
|
||||
import React from "react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
@ -35,7 +35,7 @@ import {
|
||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import ShareModal from '@/features/share/components/share-modal.tsx';
|
||||
import ShareModal from "@/features/share/components/share-modal.tsx";
|
||||
|
||||
interface PageHeaderMenuProps {
|
||||
readOnly?: boolean;
|
||||
@ -59,7 +59,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<ShareModal readOnly={readOnly}/>
|
||||
<ShareModal readOnly={readOnly} />
|
||||
|
||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
@ -106,7 +106,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
||||
] = useDisclosure(false);
|
||||
const [pageEditor] = useAtom(pageEditorAtom);
|
||||
const pageUpdatedAt = useTimeAgo(page.updatedAt);
|
||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
|
||||
@ -46,6 +46,7 @@ export default function MovePageModal({
|
||||
message: t("Page moved successfully"),
|
||||
});
|
||||
onClose();
|
||||
setTargetSpace(null);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err.response?.data.message || "An error occurred",
|
||||
@ -53,7 +54,6 @@ export default function MovePageModal({
|
||||
});
|
||||
console.log(err);
|
||||
}
|
||||
setTargetSpace(null);
|
||||
};
|
||||
|
||||
const handleChange = (space: ISpace) => {
|
||||
@ -69,7 +69,7 @@ export default function MovePageModal({
|
||||
yOffset="10vh"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
@ -78,7 +78,9 @@ export default function MovePageModal({
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Text mb="xs" c="dimmed" size="sm">{t("Move page to a different space.")}</Text>
|
||||
<Text mb="xs" c="dimmed" size="sm">
|
||||
{t("Move page to a different space.")}
|
||||
</Text>
|
||||
|
||||
<SpaceSelect
|
||||
value={currentSpaceSlug}
|
||||
|
||||
@ -1,18 +1,38 @@
|
||||
import { Modal, Button, SimpleGrid, FileButton } from "@mantine/core";
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
SimpleGrid,
|
||||
FileButton,
|
||||
Group,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrandNotion,
|
||||
IconCheck,
|
||||
IconFileCode,
|
||||
IconFileTypeZip,
|
||||
IconMarkdown,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { importPage } from "@/features/page/services/page-service.ts";
|
||||
import {
|
||||
importPage,
|
||||
importZip,
|
||||
} from "@/features/page/services/page-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { buildTree } from "@/features/page/tree/utils";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
|
||||
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts";
|
||||
import { formatBytes } from "@/lib";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { getFileTaskById } from "@/features/file-task/services/file-task-service.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
|
||||
interface PageImportModalProps {
|
||||
spaceId: string;
|
||||
@ -36,6 +56,7 @@ export default function PageImportModal({
|
||||
yOffset="10vh"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
keepMounted={true}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
@ -59,6 +80,133 @@ interface ImportFormatSelection {
|
||||
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
const { t } = useTranslation();
|
||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const [fileTaskId, setFileTaskId] = useState<string | null>(null);
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
|
||||
|
||||
const handleZipUpload = async (selectedFile: File, source: string) => {
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
onClose();
|
||||
|
||||
notifications.show({
|
||||
id: "import",
|
||||
title: t("Uploading import file"),
|
||||
message: t("Please don't close this tab."),
|
||||
loading: true,
|
||||
withCloseButton: false,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const importTask = await importZip(selectedFile, spaceId, source);
|
||||
notifications.update({
|
||||
id: "import",
|
||||
title: t("Importing pages"),
|
||||
message: t(
|
||||
"Page import is in progress. You can check back later if this takes longer.",
|
||||
),
|
||||
loading: true,
|
||||
withCloseButton: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
setFileTaskId(importTask.id);
|
||||
} catch (err) {
|
||||
console.log("Failed to upload import file", err);
|
||||
notifications.update({
|
||||
id: "import",
|
||||
color: "red",
|
||||
title: t("Failed to upload import file"),
|
||||
message: err?.response.data.message,
|
||||
icon: <IconX size={18} />,
|
||||
loading: false,
|
||||
withCloseButton: true,
|
||||
autoClose: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileTaskId) return;
|
||||
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
const fileTask = await getFileTaskById(fileTaskId);
|
||||
const status = fileTask.status;
|
||||
|
||||
if (status === "success") {
|
||||
notifications.update({
|
||||
id: "import",
|
||||
color: "teal",
|
||||
title: t("Import complete"),
|
||||
message: t("Your pages were successfully imported."),
|
||||
icon: <IconCheck size={18} />,
|
||||
loading: false,
|
||||
withCloseButton: true,
|
||||
autoClose: false,
|
||||
});
|
||||
clearInterval(intervalId);
|
||||
setFileTaskId(null);
|
||||
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ["root-sidebar-pages", fileTask.spaceId],
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "refetchRootTreeNodeEvent",
|
||||
spaceId: spaceId,
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
notifications.update({
|
||||
id: "import",
|
||||
color: "red",
|
||||
title: t("Page import failed"),
|
||||
message: t(
|
||||
"Something went wrong while importing pages: {{reason}}.",
|
||||
{
|
||||
reason: fileTask.errorMessage,
|
||||
},
|
||||
),
|
||||
icon: <IconX size={18} />,
|
||||
loading: false,
|
||||
withCloseButton: true,
|
||||
autoClose: false,
|
||||
});
|
||||
clearInterval(intervalId);
|
||||
setFileTaskId(null);
|
||||
console.error(fileTask.errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
notifications.update({
|
||||
id: "import",
|
||||
color: "red",
|
||||
title: t("Import failed"),
|
||||
message: t(
|
||||
"Something went wrong while importing pages: {{reason}}.",
|
||||
{
|
||||
reason: err.response?.data.message,
|
||||
},
|
||||
),
|
||||
icon: <IconX size={18} />,
|
||||
loading: false,
|
||||
withCloseButton: true,
|
||||
autoClose: false,
|
||||
});
|
||||
clearInterval(intervalId);
|
||||
setFileTaskId(null);
|
||||
console.error("Failed to fetch import status", err);
|
||||
}
|
||||
}, 3000);
|
||||
}, [fileTaskId]);
|
||||
|
||||
const handleFileUpload = async (selectedFiles: File[]) => {
|
||||
if (!selectedFiles) {
|
||||
@ -120,6 +268,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
return (
|
||||
<>
|
||||
<SimpleGrid cols={2}>
|
||||
@ -148,7 +297,76 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
|
||||
<FileButton
|
||||
onChange={(file) => handleZipUpload(file, "notion")}
|
||||
accept="application/zip"
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
justify="start"
|
||||
variant="default"
|
||||
leftSection={<IconBrandNotion size={18} />}
|
||||
{...props}
|
||||
>
|
||||
Notion
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
<FileButton
|
||||
onChange={(file) => handleZipUpload(file, "confluence")}
|
||||
accept="application/zip"
|
||||
>
|
||||
{(props) => (
|
||||
<Tooltip
|
||||
label="Available in enterprise edition"
|
||||
disabled={canUseConfluence}
|
||||
>
|
||||
<Button
|
||||
disabled={!canUseConfluence}
|
||||
justify="start"
|
||||
variant="default"
|
||||
leftSection={<ConfluenceIcon size={18} />}
|
||||
{...props}
|
||||
>
|
||||
Confluence
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</FileButton>
|
||||
</SimpleGrid>
|
||||
|
||||
<Group justify="center" gap="xl" mih={150}>
|
||||
<div>
|
||||
<Text ta="center" size="lg" inline>
|
||||
Import zip file
|
||||
</Text>
|
||||
<Text ta="center" size="sm" c="dimmed" inline py="sm">
|
||||
{t(
|
||||
`Upload zip file containing Markdown and HTML files. Max: {{sizeLimit}}`,
|
||||
{
|
||||
sizeLimit: formatBytes(getFileImportSizeLimit()),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
<FileButton
|
||||
onChange={(file) => handleZipUpload(file, "generic")}
|
||||
accept="application/zip"
|
||||
>
|
||||
{(props) => (
|
||||
<Group justify="center">
|
||||
<Button
|
||||
justify="center"
|
||||
leftSection={<IconFileTypeZip size={18} />}
|
||||
{...props}
|
||||
>
|
||||
{t("Upload file")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</FileButton>
|
||||
</div>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
UseInfiniteQueryResult,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
@ -14,6 +17,7 @@ import {
|
||||
movePage,
|
||||
getPageBreadcrumbs,
|
||||
getRecentChanges,
|
||||
getAllSidebarPages,
|
||||
} from "@/features/page/services/page-service";
|
||||
import {
|
||||
IMovePage,
|
||||
@ -56,35 +60,49 @@ export function useCreatePageMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||
mutationFn: (data) => createPage(data),
|
||||
onSuccess: (data) => {},
|
||||
onSuccess: (data) => {
|
||||
invalidateOnCreatePage(data);
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: t("Failed to create page"), color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePageMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
export function updatePageData(data: IPage) {
|
||||
const pageBySlug = queryClient.getQueryData<IPage>([
|
||||
"pages",
|
||||
data.slugId,
|
||||
]);
|
||||
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
||||
|
||||
if (pageBySlug) {
|
||||
queryClient.setQueryData(["pages", data.slugId], {
|
||||
...pageBySlug,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
if (pageById) {
|
||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
||||
}
|
||||
|
||||
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
|
||||
}
|
||||
|
||||
export function useUpdateTitlePageMutation() {
|
||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||
mutationFn: (data) => updatePage(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePageMutation() {
|
||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||
mutationFn: (data) => updatePage(data),
|
||||
onSuccess: (data) => {
|
||||
const pageBySlug = queryClient.getQueryData<IPage>([
|
||||
"pages",
|
||||
data.slugId,
|
||||
]);
|
||||
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
||||
updatePage(data);
|
||||
|
||||
if (pageBySlug) {
|
||||
queryClient.setQueryData(["pages", data.slugId], {
|
||||
...pageBySlug,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
if (pageById) {
|
||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
||||
}
|
||||
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -93,8 +111,9 @@ export function useDeletePageMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => deletePage(pageId),
|
||||
onSuccess: () => {
|
||||
onSuccess: (data, pageId) => {
|
||||
notifications.show({ message: t("Page deleted successfully") });
|
||||
invalidateOnDeletePage(pageId);
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: t("Failed to delete page"), color: "red" });
|
||||
@ -105,15 +124,21 @@ export function useDeletePageMutation() {
|
||||
export function useMovePageMutation() {
|
||||
return useMutation<void, Error, IMovePage>({
|
||||
mutationFn: (data) => movePage(data),
|
||||
onSuccess: () => {
|
||||
invalidateOnMovePage();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetSidebarPagesQuery(
|
||||
data: SidebarPagesParams,
|
||||
): UseQueryResult<IPagination<IPage>, Error> {
|
||||
return useQuery({
|
||||
export function useGetSidebarPagesQuery(data: SidebarPagesParams|null): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["sidebar-pages", data],
|
||||
queryFn: () => getSidebarPages(data),
|
||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
||||
initialPageParam: 1,
|
||||
getPreviousPageParam: (firstPage) =>
|
||||
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@ -141,14 +166,16 @@ export function usePageBreadcrumbsQuery(
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAncestorChildren(params: SidebarPagesParams) {
|
||||
export async function fetchAllAncestorChildren(params: SidebarPagesParams) {
|
||||
// not using a hook here, so we can call it inside a useEffect hook
|
||||
const response = await queryClient.fetchQuery({
|
||||
queryKey: ["sidebar-pages", params],
|
||||
queryFn: () => getSidebarPages(params),
|
||||
queryFn: () => getAllSidebarPages(params),
|
||||
staleTime: 30 * 60 * 1000,
|
||||
});
|
||||
return buildTree(response.items);
|
||||
|
||||
const allItems = response.pages.flatMap((page) => page.items);
|
||||
return buildTree(allItems);
|
||||
}
|
||||
|
||||
export function useRecentChangesQuery(
|
||||
@ -160,3 +187,157 @@ export function useRecentChangesQuery(
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
const newPage: Partial<IPage> = {
|
||||
creatorId: data.creatorId,
|
||||
hasChildren: data.hasChildren,
|
||||
icon: data.icon,
|
||||
id: data.id,
|
||||
parentPageId: data.parentPageId,
|
||||
position: data.position,
|
||||
slugId: data.slugId,
|
||||
spaceId: data.spaceId,
|
||||
title: data.title,
|
||||
};
|
||||
|
||||
let queryKey: QueryKey = null;
|
||||
if (data.parentPageId===null) {
|
||||
queryKey = ['root-sidebar-pages', data.spaceId];
|
||||
}else{
|
||||
queryKey = ['sidebar-pages', {pageId: data.parentPageId, spaceId: data.spaceId}]
|
||||
}
|
||||
|
||||
//update all sidebar pages
|
||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page,index) => {
|
||||
if (index === old.pages.length - 1) {
|
||||
return {
|
||||
...page,
|
||||
items: [...page.items, newPage],
|
||||
};
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
//update sidebar haschildren
|
||||
if (data.parentPageId!==null){
|
||||
//update sub sidebar pages haschildern
|
||||
const subSideBarMatches = queryClient.getQueriesData({
|
||||
queryKey: ['sidebar-pages'],
|
||||
exact: false,
|
||||
});
|
||||
|
||||
subSideBarMatches.forEach(([key, d]) => {
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((sidebarPage: IPage) =>
|
||||
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
|
||||
)
|
||||
})),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
//update root sidebar pages haschildern
|
||||
const rootSideBarMatches = queryClient.getQueriesData({
|
||||
queryKey: ['root-sidebar-pages', data.spaceId],
|
||||
exact: false,
|
||||
});
|
||||
|
||||
rootSideBarMatches.forEach(([key, d]) => {
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((sidebarPage: IPage) =>
|
||||
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
|
||||
)
|
||||
})),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes", data.spaceId],
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateOnUpdatePage(spaceId: string, parentPageId: string, id: string, title: string, icon: string) {
|
||||
let queryKey: QueryKey = null;
|
||||
if(parentPageId===null){
|
||||
queryKey = ['root-sidebar-pages', spaceId];
|
||||
}else{
|
||||
queryKey = ['sidebar-pages', {pageId: parentPageId, spaceId: spaceId}]
|
||||
}
|
||||
//update all sidebar pages
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((sidebarPage: IPage) =>
|
||||
sidebarPage.id === id ? { ...sidebarPage, title: title, icon: icon } : sidebarPage
|
||||
)
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes", spaceId],
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateOnMovePage() {
|
||||
//for move invalidate all sidebars for now (how to do???)
|
||||
//invalidate all root sidebar pages
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["root-sidebar-pages"],
|
||||
});
|
||||
//invalidate all sub sidebar pages
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['sidebar-pages'],
|
||||
});
|
||||
// ---
|
||||
}
|
||||
|
||||
export function invalidateOnDeletePage(pageId: string) {
|
||||
//update all sidebar pages
|
||||
const allSideBarMatches = queryClient.getQueriesData({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === 'root-sidebar-pages' || query.queryKey[0] === 'sidebar-pages',
|
||||
});
|
||||
|
||||
allSideBarMatches.forEach(([key, d]) => {
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((sidebarPage: IPage) => sidebarPage.id !== pageId),
|
||||
})),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes"],
|
||||
});
|
||||
}
|
||||
@ -1,14 +1,17 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
ICopyPageToSpace,
|
||||
IExportPageParams,
|
||||
IMovePage,
|
||||
IMovePageToSpace,
|
||||
IPage,
|
||||
IPageInput,
|
||||
SidebarPagesParams,
|
||||
} from "@/features/page/types/page.types";
|
||||
} from '@/features/page/types/page.types';
|
||||
import { IAttachment, IPagination } from "@/lib/types.ts";
|
||||
import { saveAs } from "file-saver";
|
||||
import { InfiniteData } from "@tanstack/react-query";
|
||||
import { IFileTask } from '@/features/file-task/types/file-task.types.ts';
|
||||
|
||||
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/pages/create", data);
|
||||
@ -39,6 +42,11 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
|
||||
await api.post<void>("/pages/move-to-space", data);
|
||||
}
|
||||
|
||||
export async function copyPageToSpace(data: ICopyPageToSpace): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/pages/copy-to-space", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getSidebarPages(
|
||||
params: SidebarPagesParams,
|
||||
): Promise<IPagination<IPage>> {
|
||||
@ -46,6 +54,32 @@ export async function getSidebarPages(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getAllSidebarPages(
|
||||
params: SidebarPagesParams,
|
||||
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
|
||||
let page = 1;
|
||||
let hasNextPage = false;
|
||||
const pages: IPagination<IPage>[] = [];
|
||||
const pageParams: number[] = [];
|
||||
|
||||
do {
|
||||
const req = await api.post("/pages/sidebar-pages", { ...params, page: page });
|
||||
|
||||
const data: IPagination<IPage> = req.data;
|
||||
pages.push(data);
|
||||
pageParams.push(page);
|
||||
|
||||
hasNextPage = data.meta.hasNextPage;
|
||||
|
||||
page += 1;
|
||||
} while (hasNextPage);
|
||||
|
||||
return {
|
||||
pageParams,
|
||||
pages,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPageBreadcrumbs(
|
||||
pageId: string,
|
||||
): Promise<Partial<IPage[]>> {
|
||||
@ -86,6 +120,25 @@ export async function importPage(file: File, spaceId: string) {
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function importZip(
|
||||
file: File,
|
||||
spaceId: string,
|
||||
source?: string,
|
||||
): Promise<IFileTask> {
|
||||
const formData = new FormData();
|
||||
formData.append("spaceId", spaceId);
|
||||
formData.append("source", source);
|
||||
formData.append("file", file);
|
||||
|
||||
const req = await api.post<any>("/pages/import-zip", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
pageId: string,
|
||||
|
||||
@ -1,4 +1,19 @@
|
||||
import { atom } from "jotai";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
import { appendNodeChildren } from "../utils";
|
||||
|
||||
export const treeDataAtom = atom<SpaceTreeNode[]>([]);
|
||||
|
||||
// Atom
|
||||
export const appendNodeChildrenAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{ parentId, children }: { parentId: string; children: SpaceTreeNode[] }
|
||||
) => {
|
||||
const currentTree = get(treeDataAtom);
|
||||
const updatedTree = appendNodeChildren(currentTree, parentId, children);
|
||||
set(treeDataAtom, updatedTree);
|
||||
}
|
||||
);
|
||||
|
||||
@ -2,7 +2,7 @@ import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
import {
|
||||
fetchAncestorChildren,
|
||||
fetchAllAncestorChildren,
|
||||
useGetRootSidebarPagesQuery,
|
||||
usePageQuery,
|
||||
useUpdatePageMutation,
|
||||
@ -15,6 +15,7 @@ import {
|
||||
IconArrowRight,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconFileDescription,
|
||||
IconFileExport,
|
||||
@ -23,7 +24,10 @@ import {
|
||||
IconPointFilled,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import {
|
||||
appendNodeChildrenAtom,
|
||||
treeDataAtom,
|
||||
} from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import clsx from "clsx";
|
||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
@ -31,6 +35,7 @@ import {
|
||||
appendNodeChildren,
|
||||
buildTree,
|
||||
buildTreeWithChildren,
|
||||
mergeRootTrees,
|
||||
updateTreeNodeIcon,
|
||||
} from "@/features/page/tree/utils/utils.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
@ -60,6 +65,7 @@ import ExportModal from "@/components/common/export-modal";
|
||||
import MovePageModal from "../../components/move-page-modal.tsx";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
||||
|
||||
interface SpaceTreeProps {
|
||||
spaceId: string;
|
||||
@ -102,17 +108,17 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
const allItems = pagesData.pages.flatMap((page) => page.items);
|
||||
const treeData = buildTree(allItems);
|
||||
|
||||
if (data.length < 1 || data?.[0].spaceId !== spaceId) {
|
||||
//Thoughts
|
||||
// don't reset if there is data in state
|
||||
// we only expect to call this once on initial load
|
||||
// even if we decide to refetch, it should only update
|
||||
// and append root pages instead of resetting the entire tree
|
||||
// which looses async loaded children too
|
||||
setData(treeData);
|
||||
setIsDataLoaded(true);
|
||||
setOpenTreeNodes({});
|
||||
}
|
||||
setData((prev) => {
|
||||
// fresh space; full reset
|
||||
if (prev.length === 0 || prev[0]?.spaceId !== spaceId) {
|
||||
setIsDataLoaded(true);
|
||||
setOpenTreeNodes({});
|
||||
return treeData;
|
||||
}
|
||||
|
||||
// same space; append only missing roots
|
||||
return mergeRootTrees(prev, treeData);
|
||||
});
|
||||
}
|
||||
}, [pagesData, hasNextPage]);
|
||||
|
||||
@ -138,7 +144,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
if (ancestor.id === currentPage.id) {
|
||||
return;
|
||||
}
|
||||
const children = await fetchAncestorChildren({
|
||||
const children = await fetchAllAncestorChildren({
|
||||
pageId: ancestor.id,
|
||||
spaceId: ancestor.spaceId,
|
||||
});
|
||||
@ -235,6 +241,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
const { t } = useTranslation();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||
const [, appendChildren] = useAtom(appendNodeChildrenAtom);
|
||||
const emit = useQueryEmit();
|
||||
const { spaceSlug } = useParams();
|
||||
const timerRef = useRef(null);
|
||||
@ -260,9 +267,10 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
|
||||
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
|
||||
if (!node.data.hasChildren) return;
|
||||
if (node.data.children && node.data.children.length > 0) {
|
||||
return;
|
||||
}
|
||||
// in conflict with use-query-subscription.ts => case "addTreeNode","moveTreeNode" etc with websocket
|
||||
// if (node.data.children && node.data.children.length > 0) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
try {
|
||||
const params: SidebarPagesParams = {
|
||||
@ -270,21 +278,12 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
spaceId: node.data.spaceId,
|
||||
};
|
||||
|
||||
const newChildren = await queryClient.fetchQuery({
|
||||
queryKey: ["sidebar-pages", params],
|
||||
queryFn: () => getSidebarPages(params),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
const childrenTree = await fetchAllAncestorChildren(params);
|
||||
|
||||
appendChildren({
|
||||
parentId: node.data.id,
|
||||
children: childrenTree,
|
||||
});
|
||||
|
||||
const childrenTree = buildTree(newChildren.items);
|
||||
|
||||
const updatedTreeData = appendNodeChildren(
|
||||
treeData,
|
||||
node.data.id,
|
||||
childrenTree,
|
||||
);
|
||||
|
||||
setTreeData(updatedTreeData);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch children:", error);
|
||||
}
|
||||
@ -302,17 +301,19 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
|
||||
const handleEmojiSelect = (emoji: { native: string }) => {
|
||||
handleUpdateNodeIcon(node.id, emoji.native);
|
||||
updatePageMutation.mutateAsync({ pageId: node.id, icon: emoji.native });
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "updateOne",
|
||||
spaceId: node.data.spaceId,
|
||||
entity: ["pages"],
|
||||
id: node.id,
|
||||
payload: { icon: emoji.native },
|
||||
updatePageMutation
|
||||
.mutateAsync({ pageId: node.id, icon: emoji.native })
|
||||
.then((data) => {
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "updateOne",
|
||||
spaceId: node.data.spaceId,
|
||||
entity: ["pages"],
|
||||
id: node.id,
|
||||
payload: { icon: emoji.native, parentPageId: data.parentPageId },
|
||||
});
|
||||
}, 50);
|
||||
});
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleRemoveEmoji = () => {
|
||||
@ -448,6 +449,10 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
movePageModalOpened,
|
||||
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
||||
] = useDisclosure(false);
|
||||
const [
|
||||
copyPageModalOpened,
|
||||
{ open: openCopyPageModal, close: closeCopySpaceModal },
|
||||
] = useDisclosure(false);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
@ -511,6 +516,17 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
{t("Move")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openCopyPageModal();
|
||||
}}
|
||||
>
|
||||
{t("Copy")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
c="red"
|
||||
@ -536,6 +552,13 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
open={movePageModalOpened}
|
||||
/>
|
||||
|
||||
<CopyPageModal
|
||||
pageId={node.id}
|
||||
currentSpaceSlug={spaceSlug}
|
||||
onClose={closeCopySpaceModal}
|
||||
open={copyPageModalOpened}
|
||||
/>
|
||||
|
||||
<ExportModal
|
||||
type="page"
|
||||
id={node.id}
|
||||
@ -552,6 +575,12 @@ interface PageArrowProps {
|
||||
}
|
||||
|
||||
function PageArrow({ node, onExpandTree }: PageArrowProps) {
|
||||
useEffect(() => {
|
||||
if (node.isOpen) {
|
||||
onExpandTree();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
size={20}
|
||||
|
||||
@ -93,7 +93,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
return data;
|
||||
};
|
||||
|
||||
const onMove: MoveHandler<T> = (args: {
|
||||
const onMove: MoveHandler<T> = async (args: {
|
||||
dragIds: string[];
|
||||
dragNodes: NodeApi<T>[];
|
||||
parentId: string | null;
|
||||
@ -176,7 +176,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
};
|
||||
|
||||
try {
|
||||
movePageMutation.mutateAsync(payload);
|
||||
await movePageMutation.mutateAsync(payload);
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
@ -206,6 +206,23 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
}
|
||||
};
|
||||
|
||||
const isPageInNode = (
|
||||
node: { data: SpaceTreeNode; children?: any[] },
|
||||
pageSlug: string
|
||||
): boolean => {
|
||||
if (node.data.slugId === pageSlug) {
|
||||
return true;
|
||||
}
|
||||
for (const item of node.children) {
|
||||
if (item.data.slugId === pageSlug) {
|
||||
return true;
|
||||
} else {
|
||||
return isPageInNode(item, pageSlug);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
||||
try {
|
||||
await deletePageMutation.mutateAsync(args.ids[0]);
|
||||
@ -218,8 +235,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
tree.drop({ id: args.ids[0] });
|
||||
setData(tree.data);
|
||||
|
||||
// navigate only if the current url is same as the deleted page
|
||||
if (pageSlug && node.data.slugId === pageSlug.split("-")[1]) {
|
||||
if (pageSlug && isPageInNode(node, pageSlug.split("-")[1])) {
|
||||
navigate(getSpaceUrl(spaceSlug));
|
||||
}
|
||||
|
||||
|
||||
@ -121,7 +121,6 @@ export const deleteTreeNode = (
|
||||
.filter((node) => node !== null);
|
||||
};
|
||||
|
||||
|
||||
export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
|
||||
const nodeMap = {};
|
||||
let result: SpaceTreeNode[] = [];
|
||||
@ -164,16 +163,55 @@ export function appendNodeChildren(
|
||||
nodeId: string,
|
||||
children: SpaceTreeNode[],
|
||||
) {
|
||||
return treeItems.map((nodeItem) => {
|
||||
if (nodeItem.id === nodeId) {
|
||||
return { ...nodeItem, children };
|
||||
}
|
||||
if (nodeItem.children) {
|
||||
// Preserve deeper children if they exist and remove node if deleted
|
||||
return treeItems.map((node) => {
|
||||
if (node.id === nodeId) {
|
||||
const newIds = new Set(children.map((c) => c.id));
|
||||
|
||||
const existingMap = new Map(
|
||||
(node.children ?? [])
|
||||
.filter((c) => newIds.has(c.id))
|
||||
.map((c) => [c.id, c]),
|
||||
);
|
||||
|
||||
const merged = children.map((newChild) => {
|
||||
const existing = existingMap.get(newChild.id);
|
||||
return existing && existing.children
|
||||
? { ...newChild, children: existing.children }
|
||||
: newChild;
|
||||
});
|
||||
|
||||
return {
|
||||
...nodeItem,
|
||||
children: appendNodeChildren(nodeItem.children, nodeId, children),
|
||||
...node,
|
||||
children: merged,
|
||||
};
|
||||
}
|
||||
return nodeItem;
|
||||
|
||||
if (node.children) {
|
||||
return {
|
||||
...node,
|
||||
children: appendNodeChildren(node.children, nodeId, children),
|
||||
};
|
||||
}
|
||||
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge root nodes; keep existing ones intact, append new ones,
|
||||
*/
|
||||
export function mergeRootTrees(
|
||||
prevRoots: SpaceTreeNode[],
|
||||
incomingRoots: SpaceTreeNode[],
|
||||
): SpaceTreeNode[] {
|
||||
const seen = new Set(prevRoots.map((r) => r.id));
|
||||
|
||||
// add new roots that were not present before
|
||||
const merged = [...prevRoots];
|
||||
incomingRoots.forEach((node) => {
|
||||
if (!seen.has(node.id)) merged.push(node);
|
||||
});
|
||||
|
||||
return sortPositionKeys(merged);
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ export interface IPage {
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
isLocked: boolean;
|
||||
lastUpdatedById: Date;
|
||||
lastUpdatedById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date;
|
||||
@ -47,6 +47,11 @@ export interface IMovePageToSpace {
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
export interface ICopyPageToSpace {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
export interface SidebarPagesParams {
|
||||
spaceId: string;
|
||||
pageId?: string;
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
.root {
|
||||
height: 34px;
|
||||
padding-left: var(--mantine-spacing-sm);
|
||||
padding-right: 4px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
color: var(--mantine-color-placeholder);
|
||||
border: 1px solid;
|
||||
|
||||
@mixin light {
|
||||
border-color: var(--mantine-color-gray-3);
|
||||
background-color: var(--mantine-color-white);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
border-color: var(--mantine-color-dark-4);
|
||||
background-color: var(--mantine-color-dark-6);
|
||||
}
|
||||
|
||||
@mixin rtl {
|
||||
padding-left: 4px;
|
||||
padding-right: var(--mantine-spacing-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
padding: 4px 7px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
border: 1px solid;
|
||||
font-weight: bold;
|
||||
|
||||
@mixin light {
|
||||
color: var(--mantine-color-gray-7);
|
||||
border-color: var(--mantine-color-gray-2);
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
color: var(--mantine-color-dark-0);
|
||||
border-color: var(--mantine-color-dark-7);
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import cx from "clsx";
|
||||
import {
|
||||
ActionIcon,
|
||||
BoxProps,
|
||||
ElementProps,
|
||||
Group,
|
||||
rem,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import classes from "./search-control.module.css";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SearchControlProps extends BoxProps, ElementProps<"button"> {}
|
||||
|
||||
export function SearchControl({ className, ...others }: SearchControlProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<UnstyledButton {...others} className={cx(classes.root, className)}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconSearch style={{ width: rem(15), height: rem(15) }} stroke={1.5} />
|
||||
<Text fz="sm" c="dimmed" pr={80}>
|
||||
{t("Search")}
|
||||
</Text>
|
||||
<Text fw={700} className={classes.shortcut}>
|
||||
Ctrl + K
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
interface SearchMobileControlProps {
|
||||
onSearch: () => void;
|
||||
}
|
||||
|
||||
export function SearchMobileControl({ onSearch }: SearchMobileControlProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip label={t("Search")} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
onClick={onSearch}
|
||||
size="sm"
|
||||
>
|
||||
<IconSearch size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
7
apps/client/src/features/search/constants.ts
Normal file
7
apps/client/src/features/search/constants.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { createSpotlight } from '@mantine/spotlight';
|
||||
|
||||
export const [searchSpotlightStore, searchSpotlight] = createSpotlight();
|
||||
|
||||
export const [shareSearchSpotlightStore, shareSearchSpotlight] =
|
||||
createSpotlight();
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
searchPage,
|
||||
searchShare,
|
||||
searchSuggestions,
|
||||
} from "@/features/search/services/search-service";
|
||||
import {
|
||||
@ -30,3 +31,13 @@ export function useSearchSuggestionsQuery(
|
||||
enabled: !!params.query,
|
||||
});
|
||||
}
|
||||
|
||||
export function useShareSearchQuery(
|
||||
params: IPageSearchParams,
|
||||
): UseQueryResult<IPageSearch[], Error> {
|
||||
return useQuery({
|
||||
queryKey: ["share-search", params],
|
||||
queryFn: () => searchShare(params),
|
||||
enabled: !!params.query,
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,36 +2,36 @@ import { Group, Center, Text } from "@mantine/core";
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getPageIcon } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { searchSpotlightStore } from "./constants";
|
||||
|
||||
interface SearchSpotlightProps {
|
||||
spaceId?: string;
|
||||
}
|
||||
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
||||
|
||||
const {
|
||||
data: searchResults,
|
||||
isLoading,
|
||||
error,
|
||||
} = usePageSearchQuery({ query: debouncedSearchQuery, spaceId });
|
||||
const { data: searchResults } = usePageSearchQuery({
|
||||
query: debouncedSearchQuery,
|
||||
spaceId,
|
||||
});
|
||||
|
||||
const pages = (
|
||||
searchResults && searchResults.length > 0 ? searchResults : []
|
||||
).map((page) => (
|
||||
<Spotlight.Action
|
||||
key={page.id}
|
||||
onClick={() =>
|
||||
navigate(buildPageUrl(page.space.slug, page.slugId, page.title))
|
||||
}
|
||||
component={Link}
|
||||
//@ts-ignore
|
||||
to={buildPageUrl(page.space.slug, page.slugId, page.title)}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
<Group wrap="nowrap" w="100%">
|
||||
<Center>{getPageIcon(page?.icon)}</Center>
|
||||
@ -54,6 +54,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
return (
|
||||
<>
|
||||
<Spotlight.Root
|
||||
store={searchSpotlightStore}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
scrollable
|
||||
|
||||
@ -19,3 +19,10 @@ export async function searchSuggestions(
|
||||
const req = await api.post<ISuggestionResult>("/search/suggest", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function searchShare(
|
||||
params: IPageSearchParams,
|
||||
): Promise<IPageSearch[]> {
|
||||
const req = await api.post<IPageSearch[]>("/search/share-search", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
87
apps/client/src/features/search/share-search-spotlight.tsx
Normal file
87
apps/client/src/features/search/share-search-spotlight.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { Group, Center, Text } from "@mantine/core";
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import React, { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useShareSearchQuery } from "@/features/search/queries/search-query";
|
||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getPageIcon } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { shareSearchSpotlightStore } from "@/features/search/constants.ts";
|
||||
|
||||
interface ShareSearchSpotlightProps {
|
||||
shareId?: string;
|
||||
}
|
||||
export function ShareSearchSpotlight({ shareId }: ShareSearchSpotlightProps) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
||||
|
||||
const { data: searchResults } = useShareSearchQuery({
|
||||
query: debouncedSearchQuery,
|
||||
shareId,
|
||||
});
|
||||
|
||||
const pages = (
|
||||
searchResults && searchResults.length > 0 ? searchResults : []
|
||||
).map((page) => (
|
||||
<Spotlight.Action
|
||||
key={page.id}
|
||||
component={Link}
|
||||
//@ts-ignore
|
||||
to={buildSharedPageUrl({
|
||||
shareId: shareId,
|
||||
pageTitle: page.title,
|
||||
pageSlugId: page.slugId,
|
||||
})}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
<Group wrap="nowrap" w="100%">
|
||||
<Center>{getPageIcon(page?.icon)}</Center>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text>{page.title}</Text>
|
||||
|
||||
{page?.highlight && (
|
||||
<Text
|
||||
opacity={0.6}
|
||||
size="xs"
|
||||
dangerouslySetInnerHTML={{ __html: page.highlight }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</Spotlight.Action>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spotlight.Root
|
||||
store={shareSearchSpotlightStore}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
scrollable
|
||||
overlayProps={{
|
||||
backgroundOpacity: 0.55,
|
||||
}}
|
||||
>
|
||||
<Spotlight.Search
|
||||
placeholder={t("Search...")}
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
/>
|
||||
<Spotlight.ActionsList>
|
||||
{query.length === 0 && pages.length === 0 && (
|
||||
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
|
||||
)}
|
||||
|
||||
{query.length > 0 && pages.length === 0 && (
|
||||
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
|
||||
)}
|
||||
|
||||
{pages.length > 0 && pages}
|
||||
</Spotlight.ActionsList>
|
||||
</Spotlight.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -35,4 +35,5 @@ export interface ISuggestionResult {
|
||||
export interface IPageSearchParams {
|
||||
query: string;
|
||||
spaceId?: string;
|
||||
shareId?: string;
|
||||
}
|
||||
|
||||
16
apps/client/src/features/share/components/share-branding.tsx
Normal file
16
apps/client/src/features/share/components/share-branding.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { Affix, Button } from "@mantine/core";
|
||||
|
||||
export default function ShareBranding() {
|
||||
return (
|
||||
<Affix position={{ bottom: 20, right: 20 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
component="a"
|
||||
target="_blank"
|
||||
href="https://docmost.com?ref=public-share"
|
||||
>
|
||||
Powered by Docmost
|
||||
</Button>
|
||||
</Affix>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Affix,
|
||||
@ -30,7 +30,13 @@ import {
|
||||
import { IconList } from "@tabler/icons-react";
|
||||
import { useToggleToc } from "@/features/share/hooks/use-toggle-toc.ts";
|
||||
import classes from "./share.module.css";
|
||||
import { useClickOutside } from "@mantine/hooks";
|
||||
import {
|
||||
SearchControl,
|
||||
SearchMobileControl,
|
||||
} from "@/features/search/components/search-control.tsx";
|
||||
import { ShareSearchSpotlight } from "@/features/search/share-search-spotlight";
|
||||
import { shareSearchSpotlight } from "@/features/search/constants";
|
||||
import ShareBranding from '@/features/share/components/share-branding.tsx';
|
||||
|
||||
const MemoizedSharedTree = React.memo(SharedTree);
|
||||
|
||||
@ -54,21 +60,9 @@ export default function ShareShell({
|
||||
const { data } = useGetSharedPageTreeQuery(shareId);
|
||||
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
|
||||
|
||||
const [navbarOutside, setNavbarOutside] = useState<HTMLElement | null>(null);
|
||||
|
||||
useClickOutside(
|
||||
() => {
|
||||
if (mobileOpened) {
|
||||
toggleMobile();
|
||||
}
|
||||
},
|
||||
null,
|
||||
[navbarOutside],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 48 }}
|
||||
header={{ height: 50 }}
|
||||
{...(data?.pageTree?.length > 1 && {
|
||||
navbar: {
|
||||
width: 300,
|
||||
@ -91,7 +85,7 @@ export default function ShareShell({
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group wrap="nowrap" justify="space-between" py="sm" px="xl">
|
||||
<Group>
|
||||
<Group wrap="nowrap">
|
||||
{data?.pageTree?.length > 1 && (
|
||||
<>
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
@ -116,8 +110,21 @@ export default function ShareShell({
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{shareId && (
|
||||
<Group visibleFrom="sm">
|
||||
<SearchControl onClick={shareSearchSpotlight.open} />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group>
|
||||
<>
|
||||
{shareId && (
|
||||
<Group hiddenFrom="sm">
|
||||
<SearchMobileControl onSearch={shareSearchSpotlight.open} />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Tooltip label={t("Table of contents")} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
@ -149,11 +156,7 @@ export default function ShareShell({
|
||||
</AppShell.Header>
|
||||
|
||||
{data?.pageTree?.length > 1 && (
|
||||
<AppShell.Navbar
|
||||
p="md"
|
||||
className={classes.navbar}
|
||||
ref={setNavbarOutside}
|
||||
>
|
||||
<AppShell.Navbar p="md" className={classes.navbar}>
|
||||
<MemoizedSharedTree sharedPageTree={data} />
|
||||
</AppShell.Navbar>
|
||||
)}
|
||||
@ -161,16 +164,7 @@ export default function ShareShell({
|
||||
<AppShell.Main>
|
||||
{children}
|
||||
|
||||
<Affix position={{ bottom: 20, right: 20 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
component="a"
|
||||
target="_blank"
|
||||
href="https://docmost.com?ref=public-share"
|
||||
>
|
||||
Powered by Docmost
|
||||
</Button>
|
||||
</Affix>
|
||||
{data && shareId && !data.hasLicenseKey && <ShareBranding />}
|
||||
</AppShell.Main>
|
||||
|
||||
<AppShell.Aside
|
||||
@ -186,6 +180,8 @@ export default function ShareShell({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AppShell.Aside>
|
||||
|
||||
<ShareSearchSpotlight shareId={shareId} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import clsx from "clsx";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconFileDescription,
|
||||
IconPointFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import { ActionIcon, Box } from "@mantine/core";
|
||||
@ -23,6 +24,7 @@ import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
||||
import styles from "./share.module.css";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||
|
||||
interface SharedTree {
|
||||
sharedPageTree: ISharedPageTree;
|
||||
@ -141,6 +143,20 @@ function Node({ node, style, tree }: NodeRendererProps<any>) {
|
||||
}}
|
||||
>
|
||||
<PageArrow node={node} />
|
||||
<div style={{ marginRight: "4px" }}>
|
||||
<EmojiPicker
|
||||
onEmojiSelect={() => {}}
|
||||
icon={
|
||||
node.data.icon ? (
|
||||
node.data.icon
|
||||
) : (
|
||||
<IconFileDescription size="18" />
|
||||
)
|
||||
}
|
||||
readOnly={true}
|
||||
removeEmojiAction={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
||||
</Box>
|
||||
</>
|
||||
|
||||
@ -41,6 +41,7 @@ export interface ISharedPage extends IShare {
|
||||
level: number;
|
||||
sharedPage: { id: string; slugId: string; title: string; icon: string };
|
||||
};
|
||||
hasLicenseKey: boolean;
|
||||
}
|
||||
|
||||
export interface IShareForPage extends IShare {
|
||||
@ -70,4 +71,5 @@ export interface IShareInfoInput {
|
||||
export interface ISharedPageTree {
|
||||
share: IShare;
|
||||
pageTree: Partial<IPage[]>;
|
||||
hasLicenseKey: boolean;
|
||||
}
|
||||
|
||||
@ -11,11 +11,13 @@ export type SharedPageTreeNode = {
|
||||
parentPageId: string;
|
||||
hasChildren: boolean;
|
||||
children: SharedPageTreeNode[];
|
||||
label: string,
|
||||
value: string,
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function buildSharedPageTree(pages: Partial<IPage[]>): SharedPageTreeNode[] {
|
||||
export function buildSharedPageTree(
|
||||
pages: Partial<IPage[]>,
|
||||
): SharedPageTreeNode[] {
|
||||
const pageMap: Record<string, SharedPageTreeNode> = {};
|
||||
|
||||
// Initialize each page as a tree node and store it in a map.
|
||||
@ -30,7 +32,7 @@ export function buildSharedPageTree(pages: Partial<IPage[]>): SharedPageTreeNode
|
||||
hasChildren: false,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId,
|
||||
label: page.title || 'untitled',
|
||||
label: page.title || "untitled",
|
||||
value: page.id,
|
||||
children: [],
|
||||
};
|
||||
@ -55,6 +57,12 @@ export function buildSharedPageTree(pages: Partial<IPage[]>): SharedPageTreeNode
|
||||
}
|
||||
});
|
||||
|
||||
// Return the sorted tree.
|
||||
return sortPositionKeys(tree);
|
||||
function sortTree(nodes: SharedPageTreeNode[]): SharedPageTreeNode[] {
|
||||
return sortPositionKeys(nodes).map((node: SharedPageTreeNode) => ({
|
||||
...node,
|
||||
children: sortTree(node.children),
|
||||
}));
|
||||
}
|
||||
|
||||
return sortTree(tree);
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ export function SpaceSelect({
|
||||
nothingFoundMessage={t("No space found")}
|
||||
limit={50}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ width, withinPortal: false }}
|
||||
comboboxProps={{ width, withinPortal: true, position: "bottom" }}
|
||||
dropdownOpened={opened}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { spotlight } from "@mantine/spotlight";
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconDots,
|
||||
@ -16,9 +15,8 @@ import {
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import classes from "./space-sidebar.module.css";
|
||||
import React, { useMemo } from "react";
|
||||
import React from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
|
||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
@ -40,6 +38,7 @@ import { SwitchSpace } from "./switch-space";
|
||||
import ExportModal from "@/components/common/export-modal";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import { searchSpotlight } from "@/features/search/constants";
|
||||
|
||||
export function SpaceSidebar() {
|
||||
const { t } = useTranslation();
|
||||
@ -51,7 +50,7 @@ export function SpaceSidebar() {
|
||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||
|
||||
const { spaceSlug } = useParams();
|
||||
const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
|
||||
const spaceRules = space?.membership?.permissions;
|
||||
const spaceAbility = useSpaceAbility(spaceRules);
|
||||
@ -100,7 +99,10 @@ export function SpaceSidebar() {
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
|
||||
<UnstyledButton className={classes.menu} onClick={spotlight.open}>
|
||||
<UnstyledButton
|
||||
className={classes.menu}
|
||||
onClick={searchSpotlight.open}
|
||||
>
|
||||
<div className={classes.menuItemInner}>
|
||||
<IconSearch
|
||||
size={18}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
|
||||
export type InvalidateEvent = {
|
||||
operation: "invalidate";
|
||||
@ -7,12 +8,17 @@ export type InvalidateEvent = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type InvalidateCommentsEvent = {
|
||||
operation: "invalidateComment";
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export type UpdateEvent = {
|
||||
operation: "updateOne";
|
||||
spaceId: string;
|
||||
entity: Array<string>;
|
||||
id: string;
|
||||
payload: Partial<any>;
|
||||
payload: Partial<IPage>;
|
||||
};
|
||||
|
||||
export type DeleteEvent = {
|
||||
@ -20,7 +26,7 @@ export type DeleteEvent = {
|
||||
spaceId: string;
|
||||
entity: Array<string>;
|
||||
id: string;
|
||||
payload?: Partial<any>;
|
||||
payload?: Partial<IPage>;
|
||||
};
|
||||
|
||||
export type AddTreeNodeEvent = {
|
||||
@ -41,15 +47,28 @@ export type MoveTreeNodeEvent = {
|
||||
parentId: string;
|
||||
index: number;
|
||||
position: string;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export type DeleteTreeNodeEvent = {
|
||||
operation: "deleteTreeNode";
|
||||
spaceId: string;
|
||||
payload: {
|
||||
node: SpaceTreeNode
|
||||
}
|
||||
node: SpaceTreeNode;
|
||||
};
|
||||
};
|
||||
|
||||
export type WebSocketEvent = InvalidateEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent;
|
||||
export type RefetchRootTreeNodeEvent = {
|
||||
operation: "refetchRootTreeNodeEvent";
|
||||
spaceId: string;
|
||||
};
|
||||
|
||||
export type WebSocketEvent =
|
||||
| InvalidateEvent
|
||||
| InvalidateCommentsEvent
|
||||
| UpdateEvent
|
||||
| DeleteEvent
|
||||
| AddTreeNodeEvent
|
||||
| MoveTreeNodeEvent
|
||||
| DeleteTreeNodeEvent
|
||||
| RefetchRootTreeNodeEvent;
|
||||
|
||||
@ -1,8 +1,18 @@
|
||||
import React from "react";
|
||||
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { InfiniteData, useQueryClient } from "@tanstack/react-query";
|
||||
import { WebSocketEvent } from "@/features/websocket/types";
|
||||
import { IPage } from "../page/types/page.types";
|
||||
import { IPagination } from "@/lib/types";
|
||||
import {
|
||||
invalidateOnCreatePage,
|
||||
invalidateOnDeletePage,
|
||||
invalidateOnMovePage,
|
||||
invalidateOnUpdatePage,
|
||||
} from "../page/queries/page-query";
|
||||
import { RQ_KEY } from "../comment/queries/comment-query";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
|
||||
export const useQuerySubscription = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@ -21,6 +31,20 @@ export const useQuerySubscription = () => {
|
||||
queryKey: [...data.entity, data.id].filter(Boolean),
|
||||
});
|
||||
break;
|
||||
case "invalidateComment":
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: RQ_KEY(data.pageId),
|
||||
});
|
||||
break;
|
||||
case "addTreeNode":
|
||||
invalidateOnCreatePage(data.payload.data);
|
||||
break;
|
||||
case "moveTreeNode":
|
||||
invalidateOnMovePage();
|
||||
break;
|
||||
case "deleteTreeNode":
|
||||
invalidateOnDeletePage(data.payload.node.id);
|
||||
break;
|
||||
case "updateOne":
|
||||
entity = data.entity[0];
|
||||
if (entity === "pages") {
|
||||
@ -31,13 +55,23 @@ export const useQuerySubscription = () => {
|
||||
}
|
||||
|
||||
// only update if data was already in cache
|
||||
if(queryClient.getQueryData([...data.entity, queryKeyId])){
|
||||
if (queryClient.getQueryData([...data.entity, queryKeyId])) {
|
||||
queryClient.setQueryData([...data.entity, queryKeyId], {
|
||||
...queryClient.getQueryData([...data.entity, queryKeyId]),
|
||||
...data.payload,
|
||||
});
|
||||
}
|
||||
|
||||
if (entity === "pages") {
|
||||
invalidateOnUpdatePage(
|
||||
data.spaceId,
|
||||
data.payload.parentPageId,
|
||||
data.id,
|
||||
data.payload.title,
|
||||
data.payload.icon,
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: [data.entity, data.id] },
|
||||
@ -51,6 +85,17 @@ export const useQuerySubscription = () => {
|
||||
);
|
||||
*/
|
||||
break;
|
||||
case "refetchRootTreeNodeEvent": {
|
||||
const spaceId = data.spaceId;
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ["root-sidebar-pages", spaceId],
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes", spaceId],
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [queryClient, socket]);
|
||||
|
||||
@ -70,10 +70,19 @@ export function getFileUploadSizeLimit() {
|
||||
return bytes(limit);
|
||||
}
|
||||
|
||||
export function getFileImportSizeLimit() {
|
||||
const limit = getConfigValue("FILE_IMPORT_SIZE_LIMIT", "200mb");
|
||||
return bytes(limit);
|
||||
}
|
||||
|
||||
export function getDrawioUrl() {
|
||||
return getConfigValue("DRAWIO_URL", "https://embed.diagrams.net");
|
||||
}
|
||||
|
||||
export function getBillingTrialDays() {
|
||||
return getConfigValue("BILLING_TRIAL_DAYS");
|
||||
}
|
||||
|
||||
function getConfigValue(key: string, defaultValue: string = undefined): string {
|
||||
const rawValue = import.meta.env.DEV
|
||||
? process?.env?.[key]
|
||||
|
||||
@ -7,8 +7,9 @@ import React, { useEffect } from "react";
|
||||
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import ShareBranding from "@/features/share/components/share-branding.tsx";
|
||||
|
||||
export default function SingleSharedPage() {
|
||||
export default function SharedPage() {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const { shareId } = useParams();
|
||||
@ -53,6 +54,8 @@ export default function SingleSharedPage() {
|
||||
content={data.page.content}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
{data && !shareId && !data.hasLicenseKey && <ShareBranding />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,10 +8,12 @@ export default defineConfig(({ mode }) => {
|
||||
const {
|
||||
APP_URL,
|
||||
FILE_UPLOAD_SIZE_LIMIT,
|
||||
FILE_IMPORT_SIZE_LIMIT,
|
||||
DRAWIO_URL,
|
||||
CLOUD,
|
||||
SUBDOMAIN_HOST,
|
||||
COLLAB_URL,
|
||||
BILLING_TRIAL_DAYS,
|
||||
} = loadEnv(mode, envPath, "");
|
||||
|
||||
return {
|
||||
@ -19,10 +21,12 @@ export default defineConfig(({ mode }) => {
|
||||
"process.env": {
|
||||
APP_URL,
|
||||
FILE_UPLOAD_SIZE_LIMIT,
|
||||
FILE_IMPORT_SIZE_LIMIT,
|
||||
DRAWIO_URL,
|
||||
CLOUD,
|
||||
SUBDOMAIN_HOST,
|
||||
COLLAB_URL,
|
||||
BILLING_TRIAL_DAYS,
|
||||
},
|
||||
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
||||
},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.20.0",
|
||||
"version": "0.20.4",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@ -31,6 +31,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.701.0",
|
||||
"@aws-sdk/lib-storage": "3.701.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.701.0",
|
||||
"@casl/ability": "^6.7.3",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
@ -56,6 +57,7 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^5.41.3",
|
||||
"cache-manager": "^6.4.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie": "^1.0.2",
|
||||
@ -80,7 +82,9 @@
|
||||
"sanitize-filename-ts": "^1.0.2",
|
||||
"socket.io": "^4.8.1",
|
||||
"stripe": "^17.5.0",
|
||||
"ws": "^8.18.0"
|
||||
"tmp-promise": "^3.0.3",
|
||||
"ws": "^8.18.0",
|
||||
"yauzl": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.20.0",
|
||||
@ -99,6 +103,7 @@
|
||||
"@types/pg": "^8.11.11",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ws": "^8.5.14",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"globals": "^15.15.0",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { AuthenticationExtension } from './extensions/authentication.extension';
|
||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||
import { CollaborationGateway } from './collaboration.gateway';
|
||||
@ -22,6 +22,7 @@ import { LoggerExtension } from './extensions/logger.extension';
|
||||
imports: [TokenModule],
|
||||
})
|
||||
export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CollaborationModule.name);
|
||||
private collabWsAdapter: CollabWsAdapter;
|
||||
private path = '/collab';
|
||||
|
||||
@ -38,7 +39,15 @@ export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
wss.on('connection', (client: WebSocket, request: IncomingMessage) => {
|
||||
this.collaborationGateway.handleConnection(client, request);
|
||||
|
||||
client.on('error', (error) => {
|
||||
this.logger.error('WebSocket client error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
wss.on('error', (error) =>
|
||||
this.logger.log('WebSocket server error:', error),
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
|
||||
@ -46,7 +46,7 @@ export const tiptapExtensions = [
|
||||
codeBlock: false,
|
||||
}),
|
||||
Comment,
|
||||
TextAlign,
|
||||
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||
TaskList,
|
||||
TaskItem,
|
||||
Underline,
|
||||
|
||||
@ -130,7 +130,7 @@ export class PersistenceExtension implements Extension {
|
||||
);
|
||||
this.contributors.delete(documentName);
|
||||
} catch (err) {
|
||||
this.logger.log('Contributors error:' + err?.['message']);
|
||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
||||
}
|
||||
|
||||
await this.pageRepo.updatePage(
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
import { jsonToNode } from '../../../collaboration/collaboration.util';
|
||||
import {
|
||||
jsonToNode,
|
||||
tiptapExtensions,
|
||||
} from '../../../collaboration/collaboration.util';
|
||||
import { validate as isValidUUID } from 'uuid';
|
||||
import { Transform } from '@tiptap/pm/transform';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export interface MentionNode {
|
||||
id: string;
|
||||
@ -58,7 +64,6 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
|
||||
return pageMentionList as MentionNode[];
|
||||
}
|
||||
|
||||
|
||||
export function getProsemirrorContent(content: any) {
|
||||
return (
|
||||
content ?? {
|
||||
@ -94,4 +99,31 @@ export function getAttachmentIds(prosemirrorJson: any) {
|
||||
});
|
||||
|
||||
return attachmentIds;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeMarkTypeFromDoc(doc: Node, markName: string): Node {
|
||||
const { schema } = doc.type;
|
||||
const markType = schema.marks[markName];
|
||||
|
||||
if (!markType) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
const tr = new Transform(doc).removeMark(0, doc.content.size, markType);
|
||||
return tr.doc;
|
||||
}
|
||||
|
||||
export function createYdocFromJson(prosemirrorJson: any): Buffer | null {
|
||||
if (prosemirrorJson) {
|
||||
const ydoc = TiptapTransformer.toYdoc(
|
||||
prosemirrorJson,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
|
||||
Y.encodeStateAsUpdate(ydoc);
|
||||
|
||||
return Buffer.from(Y.encodeStateAsUpdate(ydoc));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import * as path from 'path';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
|
||||
export const envPath = path.resolve(process.cwd(), '..', '..', '.env');
|
||||
|
||||
@ -62,3 +63,8 @@ export function extractDateFromUuid7(uuid7: string) {
|
||||
|
||||
return new Date(timestamp);
|
||||
}
|
||||
|
||||
export function sanitizeFileName(fileName: string): string {
|
||||
const sanitizedFilename = sanitize(fileName).replace(/ /g, '_');
|
||||
return sanitizedFilename.slice(0, 255);
|
||||
}
|
||||
|
||||
24
apps/server/src/core/page/dto/copy-page.dto.ts
Normal file
24
apps/server/src/core/page/dto/copy-page.dto.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class CopyPageToSpaceDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
pageId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
export type CopyPageMapEntry = {
|
||||
newPageId: string;
|
||||
newSlugId: string;
|
||||
oldSlugId: string;
|
||||
};
|
||||
|
||||
export type ICopyPageAttachment = {
|
||||
newPageId: string,
|
||||
oldPageId: string,
|
||||
oldAttachmentId: string,
|
||||
newAttachmentId: string,
|
||||
};
|
||||
@ -1,4 +1,10 @@
|
||||
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
IsNotEmpty,
|
||||
} from 'class-validator';
|
||||
|
||||
export class MovePageDto {
|
||||
@IsString()
|
||||
@ -15,9 +21,11 @@ export class MovePageDto {
|
||||
}
|
||||
|
||||
export class MovePageToSpaceDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
pageId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ import {
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { RecentPageDto } from './dto/recent-page.dto';
|
||||
import { CopyPageToSpaceDto } from './dto/copy-page.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
@ -237,6 +238,36 @@ export class PageController {
|
||||
return this.pageService.movePageToSpace(movedPage, dto.spaceId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('copy-to-space')
|
||||
async copyPageToSpace(
|
||||
@Body() dto: CopyPageToSpaceDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const copiedPage = await this.pageRepo.findById(dto.pageId);
|
||||
if (!copiedPage) {
|
||||
throw new NotFoundException('Page to copy not found');
|
||||
}
|
||||
if (copiedPage.spaceId === dto.spaceId) {
|
||||
throw new BadRequestException('Page is already in this space');
|
||||
}
|
||||
|
||||
const abilities = await Promise.all([
|
||||
this.spaceAbility.createForUser(user, copiedPage.spaceId),
|
||||
this.spaceAbility.createForUser(user, dto.spaceId),
|
||||
]);
|
||||
|
||||
if (
|
||||
abilities.some((ability) =>
|
||||
ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pageService.copyPageToSpace(copiedPage, dto.spaceId, user);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('move')
|
||||
async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) {
|
||||
|
||||
@ -2,10 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { PageService } from './services/page.service';
|
||||
import { PageController } from './page.controller';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||
|
||||
@Module({
|
||||
controllers: [PageController],
|
||||
providers: [PageService, PageHistoryService],
|
||||
exports: [PageService, PageHistoryService],
|
||||
imports: [StorageModule]
|
||||
})
|
||||
export class PageModule {}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CreatePageDto } from '../dto/create-page.dto';
|
||||
import { UpdatePageDto } from '../dto/update-page.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import {
|
||||
executeWithPagination,
|
||||
@ -21,13 +22,28 @@ import { DB } from '@docmost/db/types/db';
|
||||
import { generateSlugId } from '../../../common/helpers';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||
import { v7 as uuid7 } from 'uuid';
|
||||
import {
|
||||
createYdocFromJson,
|
||||
getAttachmentIds,
|
||||
getProsemirrorContent,
|
||||
isAttachmentNode,
|
||||
removeMarkTypeFromDoc,
|
||||
} from '../../../common/helpers/prosemirror/utils';
|
||||
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
|
||||
import { CopyPageMapEntry, ICopyPageAttachment } from '../dto/copy-page.dto';
|
||||
import { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class PageService {
|
||||
private readonly logger = new Logger(PageService.name);
|
||||
|
||||
constructor(
|
||||
private pageRepo: PageRepo,
|
||||
private attachmentRepo: AttachmentRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly storageService: StorageService,
|
||||
) {}
|
||||
|
||||
async findById(
|
||||
@ -242,6 +258,154 @@ export class PageService {
|
||||
});
|
||||
}
|
||||
|
||||
async copyPageToSpace(rootPage: Page, spaceId: string, authUser: User) {
|
||||
//TODO:
|
||||
// i. maintain internal links within copied pages
|
||||
|
||||
const nextPosition = await this.nextPagePosition(spaceId);
|
||||
|
||||
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
||||
includeContent: true,
|
||||
});
|
||||
|
||||
const pageMap = new Map<string, CopyPageMapEntry>();
|
||||
pages.forEach((page) => {
|
||||
pageMap.set(page.id, {
|
||||
newPageId: uuid7(),
|
||||
newSlugId: generateSlugId(),
|
||||
oldSlugId: page.slugId,
|
||||
});
|
||||
});
|
||||
|
||||
const attachmentMap = new Map<string, ICopyPageAttachment>();
|
||||
|
||||
const insertablePages: InsertablePage[] = await Promise.all(
|
||||
pages.map(async (page) => {
|
||||
const pageContent = getProsemirrorContent(page.content);
|
||||
const pageFromMap = pageMap.get(page.id);
|
||||
|
||||
const doc = jsonToNode(pageContent);
|
||||
const prosemirrorDoc = removeMarkTypeFromDoc(doc, 'comment');
|
||||
|
||||
const attachmentIds = getAttachmentIds(prosemirrorDoc.toJSON());
|
||||
|
||||
if (attachmentIds.length > 0) {
|
||||
attachmentIds.forEach((attachmentId: string) => {
|
||||
const newPageId = pageFromMap.newPageId;
|
||||
const newAttachmentId = uuid7();
|
||||
attachmentMap.set(attachmentId, {
|
||||
newPageId: newPageId,
|
||||
oldPageId: page.id,
|
||||
oldAttachmentId: attachmentId,
|
||||
newAttachmentId: newAttachmentId,
|
||||
});
|
||||
|
||||
prosemirrorDoc.descendants((node: PMNode) => {
|
||||
if (isAttachmentNode(node.type.name)) {
|
||||
if (node.attrs.attachmentId === attachmentId) {
|
||||
//@ts-ignore
|
||||
node.attrs.attachmentId = newAttachmentId;
|
||||
|
||||
if (node.attrs.src) {
|
||||
//@ts-ignore
|
||||
node.attrs.src = node.attrs.src.replace(
|
||||
attachmentId,
|
||||
newAttachmentId,
|
||||
);
|
||||
}
|
||||
if (node.attrs.src) {
|
||||
//@ts-ignore
|
||||
node.attrs.src = node.attrs.src.replace(
|
||||
attachmentId,
|
||||
newAttachmentId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const prosemirrorJson = prosemirrorDoc.toJSON();
|
||||
|
||||
return {
|
||||
id: pageFromMap.newPageId,
|
||||
slugId: pageFromMap.newSlugId,
|
||||
title: page.title,
|
||||
icon: page.icon,
|
||||
content: prosemirrorJson,
|
||||
textContent: jsonToText(prosemirrorJson),
|
||||
ydoc: createYdocFromJson(prosemirrorJson),
|
||||
position: page.id === rootPage.id ? nextPosition : page.position,
|
||||
spaceId: spaceId,
|
||||
workspaceId: page.workspaceId,
|
||||
creatorId: authUser.id,
|
||||
lastUpdatedById: authUser.id,
|
||||
parentPageId: page.parentPageId
|
||||
? pageMap.get(page.parentPageId)?.newPageId
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
await this.db.insertInto('pages').values(insertablePages).execute();
|
||||
|
||||
//TODO: best to handle this in a queue
|
||||
const attachmentsIds = Array.from(attachmentMap.keys());
|
||||
if (attachmentsIds.length > 0) {
|
||||
const attachments = await this.db
|
||||
.selectFrom('attachments')
|
||||
.selectAll()
|
||||
.where('id', 'in', attachmentsIds)
|
||||
.where('workspaceId', '=', rootPage.workspaceId)
|
||||
.execute();
|
||||
|
||||
for (const attachment of attachments) {
|
||||
try {
|
||||
const pageAttachment = attachmentMap.get(attachment.id);
|
||||
|
||||
// make sure the copied attachment belongs to the page it was copied from
|
||||
if (attachment.pageId !== pageAttachment.oldPageId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newAttachmentId = pageAttachment.newAttachmentId;
|
||||
|
||||
const newPageId = pageAttachment.newPageId;
|
||||
|
||||
const newPathFile = attachment.filePath.replace(
|
||||
attachment.id,
|
||||
newAttachmentId,
|
||||
);
|
||||
await this.storageService.copy(attachment.filePath, newPathFile);
|
||||
await this.db
|
||||
.insertInto('attachments')
|
||||
.values({
|
||||
id: newAttachmentId,
|
||||
type: attachment.type,
|
||||
filePath: newPathFile,
|
||||
fileName: attachment.fileName,
|
||||
fileSize: attachment.fileSize,
|
||||
mimeType: attachment.mimeType,
|
||||
fileExt: attachment.fileExt,
|
||||
creatorId: attachment.creatorId,
|
||||
workspaceId: attachment.workspaceId,
|
||||
pageId: newPageId,
|
||||
spaceId: spaceId,
|
||||
})
|
||||
.execute();
|
||||
} catch (err) {
|
||||
this.logger.log(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newPageId = pageMap.get(rootPage.id).newPageId;
|
||||
return await this.pageRepo.findById(newPageId, {
|
||||
includeSpace: true,
|
||||
});
|
||||
}
|
||||
|
||||
async movePage(dto: MovePageDto, movedPage: Page) {
|
||||
// validate position value by attempting to generate a key
|
||||
try {
|
||||
|
||||
@ -5,8 +5,11 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
|
||||
|
||||
export class SearchDTO {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
query: string;
|
||||
|
||||
@ -14,6 +17,10 @@ export class SearchDTO {
|
||||
@IsString()
|
||||
spaceId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
shareId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
creatorId?: string;
|
||||
@ -27,6 +34,16 @@ export class SearchDTO {
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export class SearchShareDTO extends SearchDTO {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
shareId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
export class SearchSuggestionDTO {
|
||||
@IsString()
|
||||
query: string;
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotImplementedException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { SearchService } from './search.service';
|
||||
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
|
||||
import {
|
||||
SearchDTO,
|
||||
SearchShareDTO,
|
||||
SearchSuggestionDTO,
|
||||
} from './dto/search.dto';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
@ -19,6 +23,7 @@ import {
|
||||
SpaceCaslSubject,
|
||||
} from '../casl/interfaces/space-ability.type';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { Public } from 'src/common/decorators/public.decorator';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('search')
|
||||
@ -30,7 +35,13 @@ export class SearchController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post()
|
||||
async pageSearch(@Body() searchDto: SearchDTO, @AuthUser() user: User) {
|
||||
async pageSearch(
|
||||
@Body() searchDto: SearchDTO,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
delete searchDto.shareId;
|
||||
|
||||
if (searchDto.spaceId) {
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
@ -40,12 +51,12 @@ export class SearchController {
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.searchService.searchPage(searchDto.query, searchDto);
|
||||
}
|
||||
|
||||
// TODO: search all spaces user is a member of if no spaceId provided
|
||||
throw new NotImplementedException();
|
||||
return this.searchService.searchPage(searchDto.query, searchDto, {
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ -57,4 +68,21 @@ export class SearchController {
|
||||
) {
|
||||
return this.searchService.searchSuggestions(dto, user.id, workspace.id);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('share-search')
|
||||
async searchShare(
|
||||
@Body() searchDto: SearchShareDTO,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
delete searchDto.spaceId;
|
||||
if (!searchDto.shareId) {
|
||||
throw new BadRequestException('shareId is required');
|
||||
}
|
||||
|
||||
return this.searchService.searchPage(searchDto.query, searchDto, {
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { sql } from 'kysely';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const tsquery = require('pg-tsquery')();
|
||||
@ -15,19 +16,24 @@ export class SearchService {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private pageRepo: PageRepo,
|
||||
private shareRepo: ShareRepo,
|
||||
private spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async searchPage(
|
||||
query: string,
|
||||
searchParams: SearchDTO,
|
||||
opts: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
},
|
||||
): Promise<SearchResponseDto[]> {
|
||||
if (query.length < 1) {
|
||||
return;
|
||||
}
|
||||
const searchQuery = tsquery(query.trim() + '*');
|
||||
|
||||
const queryResults = await this.db
|
||||
let queryResults = this.db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
@ -43,18 +49,71 @@ export class SearchService {
|
||||
'highlight',
|
||||
),
|
||||
])
|
||||
.select((eb) => this.pageRepo.withSpace(eb))
|
||||
.where('spaceId', '=', searchParams.spaceId)
|
||||
.where('tsv', '@@', sql<string>`to_tsquery(${searchQuery})`)
|
||||
.$if(Boolean(searchParams.creatorId), (qb) =>
|
||||
qb.where('creatorId', '=', searchParams.creatorId),
|
||||
)
|
||||
.orderBy('rank', 'desc')
|
||||
.limit(searchParams.limit | 20)
|
||||
.offset(searchParams.offset || 0)
|
||||
.execute();
|
||||
.offset(searchParams.offset || 0);
|
||||
|
||||
const searchResults = queryResults.map((result) => {
|
||||
if (!searchParams.shareId) {
|
||||
queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb));
|
||||
}
|
||||
|
||||
if (searchParams.spaceId) {
|
||||
// search by spaceId
|
||||
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
|
||||
} else if (opts.userId && !searchParams.spaceId) {
|
||||
// only search spaces the user is a member of
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(
|
||||
opts.userId,
|
||||
);
|
||||
if (userSpaceIds.length > 0) {
|
||||
queryResults = queryResults
|
||||
.where('spaceId', 'in', userSpaceIds)
|
||||
.where('workspaceId', '=', opts.workspaceId);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
|
||||
// search in shares
|
||||
const shareId = searchParams.shareId;
|
||||
const share = await this.shareRepo.findById(shareId);
|
||||
if (!share || share.workspaceId !== opts.workspaceId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pageIdsToSearch = [];
|
||||
if (share.includeSubPages) {
|
||||
const pageList = await this.pageRepo.getPageAndDescendants(
|
||||
share.pageId,
|
||||
{
|
||||
includeContent: false,
|
||||
},
|
||||
);
|
||||
|
||||
pageIdsToSearch.push(...pageList.map((page) => page.id));
|
||||
} else {
|
||||
pageIdsToSearch.push(share.pageId);
|
||||
}
|
||||
|
||||
if (pageIdsToSearch.length > 0) {
|
||||
queryResults = queryResults
|
||||
.where('id', 'in', pageIdsToSearch)
|
||||
.where('workspaceId', '=', opts.workspaceId);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
queryResults = await queryResults.execute();
|
||||
|
||||
//@ts-ignore
|
||||
const searchResults = queryResults.map((result: SearchResponseDto) => {
|
||||
if (result.highlight) {
|
||||
result.highlight = result.highlight
|
||||
.replace(/\r\n|\r|\n/g, ' ')
|
||||
|
||||
@ -30,6 +30,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('shares')
|
||||
@ -39,6 +40,7 @@ export class ShareController {
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly shareRepo: ShareRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ -61,7 +63,12 @@ export class ShareController {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
return this.shareService.getSharedPage(dto, workspace.id);
|
||||
return {
|
||||
...(await this.shareService.getSharedPage(dto, workspace.id)),
|
||||
hasLicenseKey:
|
||||
Boolean(workspace.licenseKey) ||
|
||||
(this.environmentService.isCloud() && workspace.plan === 'business'),
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ -166,6 +173,11 @@ export class ShareController {
|
||||
@Body() dto: ShareIdDto,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.shareService.getShareTree(dto.shareId, workspace.id);
|
||||
return {
|
||||
...(await this.shareService.getShareTree(dto.shareId, workspace.id)),
|
||||
hasLicenseKey:
|
||||
Boolean(workspace.licenseKey) ||
|
||||
(this.environmentService.isCloud() && workspace.plan === 'business'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
getAttachmentIds,
|
||||
getProsemirrorContent,
|
||||
isAttachmentNode,
|
||||
removeMarkTypeFromDoc,
|
||||
} from '../../common/helpers/prosemirror/utils';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
@ -223,11 +224,7 @@ export class ShareService {
|
||||
.end()
|
||||
.as('found'),
|
||||
])
|
||||
.where(
|
||||
isValidUUID(childPageId) ? 'id' : 'slugId',
|
||||
'=',
|
||||
childPageId,
|
||||
)
|
||||
.where(isValidUUID(childPageId) ? 'id' : 'slugId', '=', childPageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
@ -292,6 +289,7 @@ export class ShareService {
|
||||
updateAttachmentAttr(node, 'url', token);
|
||||
});
|
||||
|
||||
return doc.toJSON();
|
||||
const removeCommentMarks = removeMarkTypeFromDoc(doc, 'comment');
|
||||
return removeCommentMarks.toJSON();
|
||||
}
|
||||
}
|
||||
|
||||
@ -387,14 +387,14 @@ export class WorkspaceService {
|
||||
.replace(/[^a-z0-9]/g, '')
|
||||
.substring(0, 20);
|
||||
// Ensure we leave room for a random suffix.
|
||||
const maxSuffixLength = 3;
|
||||
const maxSuffixLength = 6;
|
||||
|
||||
if (subdomain.length < 4) {
|
||||
subdomain = `${subdomain}-${generateRandomSuffix(maxSuffixLength)}`;
|
||||
}
|
||||
|
||||
if (DISALLOWED_HOSTNAMES.includes(subdomain)) {
|
||||
subdomain = `myworkspace-${generateRandomSuffix(maxSuffixLength)}`;
|
||||
subdomain = `workspace-${generateRandomSuffix(maxSuffixLength)}`;
|
||||
}
|
||||
|
||||
let uniqueHostname = subdomain;
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('file_tasks')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
// type (import, export)
|
||||
.addColumn('type', 'varchar', (col) => col)
|
||||
// source (generic, notion, confluence)
|
||||
.addColumn('source', 'varchar', (col) => col)
|
||||
// status (pending|processing|success|failed),
|
||||
.addColumn('status', 'varchar', (col) => col)
|
||||
.addColumn('file_name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('file_path', 'varchar', (col) => col.notNull())
|
||||
.addColumn('file_size', 'int8', (col) => col)
|
||||
.addColumn('file_ext', 'varchar', (col) => col)
|
||||
.addColumn('error_message', 'varchar', (col) => col)
|
||||
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
|
||||
.addColumn('space_id', 'uuid', (col) =>
|
||||
col.references('spaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('file_tasks').execute();
|
||||
}
|
||||
@ -244,7 +244,7 @@ export class PageRepo {
|
||||
'p.spaceId',
|
||||
'p.workspaceId',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
|
||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
|
||||
),
|
||||
)
|
||||
|
||||
@ -70,7 +70,7 @@ export class UserTokenRepo {
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('type', '=', tokenType)
|
||||
.orderBy('expiresAt desc')
|
||||
.orderBy('expiresAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
||||
@ -70,7 +70,7 @@ export class WorkspaceRepo {
|
||||
return await this.db
|
||||
.selectFrom('workspaces')
|
||||
.selectAll()
|
||||
.orderBy('createdAt asc')
|
||||
.orderBy('createdAt', 'asc')
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
19
apps/server/src/database/types/db.d.ts
vendored
19
apps/server/src/database/types/db.d.ts
vendored
@ -122,6 +122,24 @@ export interface Comments {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface FileTasks {
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
deletedAt: Timestamp | null;
|
||||
errorMessage: string | null;
|
||||
fileExt: string | null;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
fileSize: Int8 | null;
|
||||
id: Generated<string>;
|
||||
source: string | null;
|
||||
spaceId: string | null;
|
||||
status: string | null;
|
||||
type: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Groups {
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
@ -298,6 +316,7 @@ export interface DB {
|
||||
backlinks: Backlinks;
|
||||
billing: Billing;
|
||||
comments: Comments;
|
||||
fileTasks: FileTasks;
|
||||
groups: Groups;
|
||||
groupUsers: GroupUsers;
|
||||
pageHistory: PageHistory;
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
AuthProviders,
|
||||
AuthAccounts,
|
||||
Shares,
|
||||
FileTasks,
|
||||
} from './db';
|
||||
|
||||
// Workspace
|
||||
@ -107,3 +108,8 @@ export type UpdatableAuthAccount = Updateable<Omit<AuthAccounts, 'id'>>;
|
||||
export type Share = Selectable<Shares>;
|
||||
export type InsertableShare = Insertable<Shares>;
|
||||
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
|
||||
|
||||
// File Task
|
||||
export type FileTask = Selectable<FileTasks>;
|
||||
export type InsertableFileTask = Insertable<FileTasks>;
|
||||
export type UpdatableFileTask = Updateable<Omit<FileTasks, 'id'>>;
|
||||
|
||||
Submodule apps/server/src/ee updated: d3095f2d8b...70eb45eaec
@ -67,6 +67,10 @@ export class EnvironmentService {
|
||||
return this.configService.get<string>('FILE_UPLOAD_SIZE_LIMIT', '50mb');
|
||||
}
|
||||
|
||||
getFileImportSizeLimit(): string {
|
||||
return this.configService.get<string>('FILE_IMPORT_SIZE_LIMIT', '200mb');
|
||||
}
|
||||
|
||||
getAwsS3AccessKeyId(): string {
|
||||
return this.configService.get<string>('AWS_S3_ACCESS_KEY_ID');
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import * as TurndownService from '@joplin/turndown';
|
||||
import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm';
|
||||
import * as path from 'path';
|
||||
|
||||
export function turndown(html: string): string {
|
||||
const turndownService = new TurndownService({
|
||||
@ -23,6 +24,7 @@ export function turndown(html: string): string {
|
||||
mathInline,
|
||||
mathBlock,
|
||||
iframeEmbed,
|
||||
video,
|
||||
]);
|
||||
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
||||
}
|
||||
@ -68,7 +70,7 @@ function taskList(turndownService: TurndownService) {
|
||||
) as HTMLInputElement;
|
||||
const isChecked = checkbox.checked;
|
||||
|
||||
return `- ${isChecked ? '[x]' : '[ ]'} ${content.trim()} \n`;
|
||||
return `- ${isChecked ? '[x]' : '[ ]'} ${content.trim()} \n`;
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -87,8 +89,12 @@ function preserveDetail(turndownService: TurndownService) {
|
||||
}
|
||||
|
||||
const detailsContent = Array.from(node.childNodes)
|
||||
.filter(child => child.nodeName !== 'SUMMARY')
|
||||
.map(child => (child.nodeType === 1 ? turndownService.turndown((child as HTMLElement).outerHTML) : child.textContent))
|
||||
.filter((child) => child.nodeName !== 'SUMMARY')
|
||||
.map((child) =>
|
||||
child.nodeType === 1
|
||||
? turndownService.turndown((child as HTMLElement).outerHTML)
|
||||
: child.textContent,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `\n<details>\n${detailSummary}\n\n${detailsContent}\n\n</details>\n`;
|
||||
@ -135,3 +141,16 @@ function iframeEmbed(turndownService: TurndownService) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function video(turndownService: TurndownService) {
|
||||
turndownService.addRule('video', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return node.tagName === 'VIDEO';
|
||||
},
|
||||
replacement: function (content: any, node: HTMLInputElement) {
|
||||
const src = node.getAttribute('src') || '';
|
||||
const name = path.basename(src);
|
||||
return '[' + name + '](' + src + ')';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
18
apps/server/src/integrations/import/dto/file-task-dto.ts
Normal file
18
apps/server/src/integrations/import/dto/file-task-dto.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||
|
||||
export class FileTaskIdDto {
|
||||
@IsNotEmpty()
|
||||
@IsUUID()
|
||||
fileTaskId: string;
|
||||
}
|
||||
|
||||
export type ImportPageNode = {
|
||||
id: string;
|
||||
slugId: string;
|
||||
name: string;
|
||||
content: string;
|
||||
position?: string | null;
|
||||
parentPageId: string | null;
|
||||
fileExtension: string;
|
||||
filePath: string;
|
||||
};
|
||||
79
apps/server/src/integrations/import/file-task.controller.ts
Normal file
79
apps/server/src/integrations/import/file-task.controller.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../core/casl/interfaces/space-ability.type';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { FileTaskIdDto } from './dto/file-task-dto';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
|
||||
@Controller('file-tasks')
|
||||
export class FileTaskController {
|
||||
constructor(
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post()
|
||||
async getFileTasks(@AuthUser() user: User) {
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(user.id);
|
||||
|
||||
if (!userSpaceIds || userSpaceIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fileTasks = await this.db
|
||||
.selectFrom('fileTasks')
|
||||
.selectAll()
|
||||
.where('spaceId', 'in', userSpaceIds)
|
||||
.execute();
|
||||
|
||||
if (!fileTasks) {
|
||||
throw new NotFoundException('File task not found');
|
||||
}
|
||||
|
||||
return fileTasks;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('info')
|
||||
async getFileTask(@Body() dto: FileTaskIdDto, @AuthUser() user: User) {
|
||||
const fileTask = await this.db
|
||||
.selectFrom('fileTasks')
|
||||
.selectAll()
|
||||
.where('id', '=', dto.fileTaskId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!fileTask || !fileTask.spaceId) {
|
||||
throw new NotFoundException('File task not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
fileTask.spaceId,
|
||||
);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return fileTask;
|
||||
}
|
||||
}
|
||||
@ -21,8 +21,9 @@ import {
|
||||
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
||||
import * as bytes from 'bytes';
|
||||
import * as path from 'path';
|
||||
import { ImportService } from './import.service';
|
||||
import { ImportService } from './services/import.service';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
|
||||
@Controller()
|
||||
export class ImportController {
|
||||
@ -31,6 +32,7 @@ export class ImportController {
|
||||
constructor(
|
||||
private readonly importService: ImportService,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
@UseInterceptors(FileInterceptor)
|
||||
@ -44,18 +46,18 @@ export class ImportController {
|
||||
) {
|
||||
const validFileExtensions = ['.md', '.html'];
|
||||
|
||||
const maxFileSize = bytes('100mb');
|
||||
const maxFileSize = bytes('10mb');
|
||||
|
||||
let file = null;
|
||||
try {
|
||||
file = await req.file({
|
||||
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
|
||||
limits: { fileSize: maxFileSize, fields: 4, files: 1 },
|
||||
});
|
||||
} catch (err: any) {
|
||||
this.logger.error(err.message);
|
||||
if (err?.statusCode === 413) {
|
||||
throw new BadRequestException(
|
||||
`File too large. Exceeds the 100mb import limit`,
|
||||
`File too large. Exceeds the 10mb import limit`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -73,7 +75,7 @@ export class ImportController {
|
||||
const spaceId = file.fields?.spaceId?.value;
|
||||
|
||||
if (!spaceId) {
|
||||
throw new BadRequestException('spaceId or format not found');
|
||||
throw new BadRequestException('spaceId is required');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
||||
@ -83,4 +85,69 @@ export class ImportController {
|
||||
|
||||
return this.importService.importPage(file, user.id, spaceId, workspace.id);
|
||||
}
|
||||
|
||||
@UseInterceptors(FileInterceptor)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('pages/import-zip')
|
||||
async importZip(
|
||||
@Req() req: any,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const validFileExtensions = ['.zip'];
|
||||
|
||||
const maxFileSize = bytes(this.environmentService.getFileImportSizeLimit());
|
||||
|
||||
let file = null;
|
||||
try {
|
||||
file = await req.file({
|
||||
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
|
||||
});
|
||||
} catch (err: any) {
|
||||
this.logger.error(err.message);
|
||||
if (err?.statusCode === 413) {
|
||||
throw new BadRequestException(
|
||||
`File too large. Exceeds the ${this.environmentService.getFileImportSizeLimit()} import limit`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
throw new BadRequestException('Failed to upload file');
|
||||
}
|
||||
|
||||
if (
|
||||
!validFileExtensions.includes(path.extname(file.filename).toLowerCase())
|
||||
) {
|
||||
throw new BadRequestException('Invalid import file extension.');
|
||||
}
|
||||
|
||||
const spaceId = file.fields?.spaceId?.value;
|
||||
const source = file.fields?.source?.value;
|
||||
|
||||
const validZipSources = ['generic', 'notion', 'confluence'];
|
||||
if (!validZipSources.includes(source)) {
|
||||
throw new BadRequestException(
|
||||
'Invalid import source. Import source must either be generic, notion or confluence.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!spaceId) {
|
||||
throw new BadRequestException('spaceId is required');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.importService.importZip(
|
||||
file,
|
||||
source,
|
||||
user.id,
|
||||
spaceId,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ImportService } from './import.service';
|
||||
import { ImportService } from './services/import.service';
|
||||
import { ImportController } from './import.controller';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { FileTaskService } from './services/file-task.service';
|
||||
import { FileTaskProcessor } from './processors/file-task.processor';
|
||||
import { ImportAttachmentService } from './services/import-attachment.service';
|
||||
import { FileTaskController } from './file-task.controller';
|
||||
import { PageModule } from '../../core/page/page.module';
|
||||
|
||||
@Module({
|
||||
providers: [ImportService],
|
||||
controllers: [ImportController],
|
||||
providers: [
|
||||
ImportService,
|
||||
FileTaskService,
|
||||
FileTaskProcessor,
|
||||
ImportAttachmentService,
|
||||
],
|
||||
exports: [ImportService, ImportAttachmentService],
|
||||
controllers: [ImportController, FileTaskController],
|
||||
imports: [StorageModule, PageModule],
|
||||
})
|
||||
export class ImportModule {}
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
||||
import { FileTaskService } from '../services/file-task.service';
|
||||
import { FileTaskStatus } from '../utils/file.utils';
|
||||
import { StorageService } from '../../storage/storage.service';
|
||||
|
||||
@Processor(QueueName.FILE_TASK_QUEUE)
|
||||
export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(FileTaskProcessor.name);
|
||||
|
||||
constructor(
|
||||
private readonly fileTaskService: FileTaskService,
|
||||
private readonly storageService: StorageService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<any, void>): Promise<void> {
|
||||
try {
|
||||
switch (job.name) {
|
||||
case QueueJob.IMPORT_TASK:
|
||||
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
|
||||
break;
|
||||
case QueueJob.EXPORT_TASK:
|
||||
// TODO: export task
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('File task failed', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@OnWorkerEvent('active')
|
||||
onActive(job: Job) {
|
||||
this.logger.debug(`Processing ${job.name} job`);
|
||||
}
|
||||
|
||||
@OnWorkerEvent('failed')
|
||||
async onFailed(job: Job) {
|
||||
this.logger.error(
|
||||
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const fileTaskId = job.data.fileTaskId;
|
||||
await this.fileTaskService.updateTaskStatus(
|
||||
fileTaskId,
|
||||
FileTaskStatus.Failed,
|
||||
job.failedReason,
|
||||
);
|
||||
|
||||
const fileTask = await this.fileTaskService.getFileTask(fileTaskId);
|
||||
if (fileTask) {
|
||||
await this.storageService.delete(fileTask.filePath);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
@OnWorkerEvent('completed')
|
||||
onCompleted(job: Job) {
|
||||
this.logger.log(
|
||||
`Completed ${job.name} job for File task ID ${job.data.fileTaskId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,346 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as path from 'path';
|
||||
import { jsonToText } from '../../../collaboration/collaboration.util';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import {
|
||||
extractZip,
|
||||
FileImportSource,
|
||||
FileTaskStatus,
|
||||
} from '../utils/file.utils';
|
||||
import { StorageService } from '../../storage/storage.service';
|
||||
import * as tmp from 'tmp-promise';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { ImportService } from './import.service';
|
||||
import { promises as fs } from 'fs';
|
||||
import { generateSlugId } from '../../../common/helpers';
|
||||
import { v7 } from 'uuid';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
import { FileTask, InsertablePage } from '@docmost/db/types/entity.types';
|
||||
import { markdownToHtml } from '@docmost/editor-ext';
|
||||
import { getProsemirrorContent } from '../../../common/helpers/prosemirror/utils';
|
||||
import { formatImportHtml } from '../utils/import-formatter';
|
||||
import {
|
||||
buildAttachmentCandidates,
|
||||
collectMarkdownAndHtmlFiles,
|
||||
} from '../utils/import.utils';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { ImportAttachmentService } from './import-attachment.service';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { PageService } from '../../../core/page/services/page.service';
|
||||
import { ImportPageNode } from '../dto/file-task-dto';
|
||||
|
||||
@Injectable()
|
||||
export class FileTaskService {
|
||||
private readonly logger = new Logger(FileTaskService.name);
|
||||
|
||||
constructor(
|
||||
private readonly storageService: StorageService,
|
||||
private readonly importService: ImportService,
|
||||
private readonly pageService: PageService,
|
||||
private readonly backlinkRepo: BacklinkRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly importAttachmentService: ImportAttachmentService,
|
||||
private moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
async processZIpImport(fileTaskId: string): Promise<void> {
|
||||
const fileTask = await this.db
|
||||
.selectFrom('fileTasks')
|
||||
.selectAll()
|
||||
.where('id', '=', fileTaskId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!fileTask) {
|
||||
this.logger.log(`Import file task with ID ${fileTaskId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileTask.status === FileTaskStatus.Failed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileTask.status === FileTaskStatus.Success) {
|
||||
this.logger.log('Imported task already processed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { path: tmpZipPath, cleanup: cleanupTmpFile } = await tmp.file({
|
||||
prefix: 'docmost-import',
|
||||
postfix: '.zip',
|
||||
discardDescriptor: true,
|
||||
});
|
||||
|
||||
const { path: tmpExtractDir, cleanup: cleanupTmpDir } = await tmp.dir({
|
||||
prefix: 'docmost-extract-',
|
||||
unsafeCleanup: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const fileStream = await this.storageService.readStream(
|
||||
fileTask.filePath,
|
||||
);
|
||||
await pipeline(fileStream, createWriteStream(tmpZipPath));
|
||||
await extractZip(tmpZipPath, tmpExtractDir);
|
||||
} catch (err) {
|
||||
await cleanupTmpFile();
|
||||
await cleanupTmpDir();
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
fileTask.source === FileImportSource.Generic ||
|
||||
fileTask.source === FileImportSource.Notion
|
||||
) {
|
||||
await this.processGenericImport({
|
||||
extractDir: tmpExtractDir,
|
||||
fileTask,
|
||||
});
|
||||
}
|
||||
|
||||
if (fileTask.source === FileImportSource.Confluence) {
|
||||
let ConfluenceModule: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
ConfluenceModule = require('./../../../ee/confluence-import/confluence-import.service');
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Confluence import requested but EE module not bundled in this build',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const confluenceImportService = this.moduleRef.get(
|
||||
ConfluenceModule.ConfluenceImportService,
|
||||
{ strict: false },
|
||||
);
|
||||
|
||||
await confluenceImportService.processConfluenceImport({
|
||||
extractDir: tmpExtractDir,
|
||||
fileTask,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await this.updateTaskStatus(fileTaskId, FileTaskStatus.Success, null);
|
||||
await cleanupTmpFile();
|
||||
await cleanupTmpDir();
|
||||
// delete stored file on success
|
||||
await this.storageService.delete(fileTask.filePath);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to delete import file from storage. Task ID: ${fileTaskId}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
await cleanupTmpFile();
|
||||
await cleanupTmpDir();
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async processGenericImport(opts: {
|
||||
extractDir: string;
|
||||
fileTask: FileTask;
|
||||
}): Promise<void> {
|
||||
const { extractDir, fileTask } = opts;
|
||||
const allFiles = await collectMarkdownAndHtmlFiles(extractDir);
|
||||
const attachmentCandidates = await buildAttachmentCandidates(extractDir);
|
||||
|
||||
const pagesMap = new Map<string, ImportPageNode>();
|
||||
|
||||
for (const absPath of allFiles) {
|
||||
const relPath = path
|
||||
.relative(extractDir, absPath)
|
||||
.split(path.sep)
|
||||
.join('/'); // normalize to forward-slashes
|
||||
const ext = path.extname(relPath).toLowerCase();
|
||||
let content = await fs.readFile(absPath, 'utf-8');
|
||||
|
||||
if (ext.toLowerCase() === '.md') {
|
||||
content = await markdownToHtml(content);
|
||||
}
|
||||
|
||||
pagesMap.set(relPath, {
|
||||
id: v7(),
|
||||
slugId: generateSlugId(),
|
||||
name: path.basename(relPath, ext),
|
||||
content,
|
||||
parentPageId: null,
|
||||
fileExtension: ext,
|
||||
filePath: relPath,
|
||||
});
|
||||
}
|
||||
|
||||
// parent/child linking
|
||||
pagesMap.forEach((page, filePath) => {
|
||||
const segments = filePath.split('/');
|
||||
segments.pop();
|
||||
let parentPage = null;
|
||||
while (segments.length) {
|
||||
const tryMd = segments.join('/') + '.md';
|
||||
const tryHtml = segments.join('/') + '.html';
|
||||
if (pagesMap.has(tryMd)) {
|
||||
parentPage = pagesMap.get(tryMd)!;
|
||||
break;
|
||||
}
|
||||
if (pagesMap.has(tryHtml)) {
|
||||
parentPage = pagesMap.get(tryHtml)!;
|
||||
break;
|
||||
}
|
||||
segments.pop();
|
||||
}
|
||||
if (parentPage) page.parentPageId = parentPage.id;
|
||||
});
|
||||
|
||||
// generate position keys
|
||||
const siblingsMap = new Map<string | null, ImportPageNode[]>();
|
||||
|
||||
pagesMap.forEach((page) => {
|
||||
const group = siblingsMap.get(page.parentPageId) ?? [];
|
||||
group.push(page);
|
||||
siblingsMap.set(page.parentPageId, group);
|
||||
});
|
||||
|
||||
// get root pages
|
||||
const rootSibs = siblingsMap.get(null);
|
||||
|
||||
if (rootSibs?.length) {
|
||||
rootSibs.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// get first position key from the server
|
||||
const nextPosition = await this.pageService.nextPagePosition(
|
||||
fileTask.spaceId,
|
||||
);
|
||||
|
||||
let prevPos: string | null = null;
|
||||
rootSibs.forEach((page, idx) => {
|
||||
if (idx === 0) {
|
||||
page.position = nextPosition;
|
||||
} else {
|
||||
page.position = generateJitteredKeyBetween(prevPos, null);
|
||||
}
|
||||
prevPos = page.position;
|
||||
});
|
||||
}
|
||||
|
||||
// non-root buckets (children & deeper levels)
|
||||
siblingsMap.forEach((sibs, parentId) => {
|
||||
if (parentId === null) return; // root already done
|
||||
|
||||
sibs.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
let prevPos: string | null = null;
|
||||
for (const page of sibs) {
|
||||
page.position = generateJitteredKeyBetween(prevPos, null);
|
||||
prevPos = page.position;
|
||||
}
|
||||
});
|
||||
|
||||
// internal page links
|
||||
const filePathToPageMetaMap = new Map<
|
||||
string,
|
||||
{ id: string; title: string; slugId: string }
|
||||
>();
|
||||
pagesMap.forEach((page) => {
|
||||
filePathToPageMetaMap.set(page.filePath, {
|
||||
id: page.id,
|
||||
title: page.name,
|
||||
slugId: page.slugId,
|
||||
});
|
||||
});
|
||||
|
||||
const pageResults = await Promise.all(
|
||||
Array.from(pagesMap.values()).map(async (page) => {
|
||||
const htmlContent =
|
||||
await this.importAttachmentService.processAttachments({
|
||||
html: page.content,
|
||||
pageRelativePath: page.filePath,
|
||||
extractDir,
|
||||
pageId: page.id,
|
||||
fileTask,
|
||||
attachmentCandidates,
|
||||
});
|
||||
|
||||
const { html, backlinks } = await formatImportHtml({
|
||||
html: htmlContent,
|
||||
currentFilePath: page.filePath,
|
||||
filePathToPageMetaMap: filePathToPageMetaMap,
|
||||
creatorId: fileTask.creatorId,
|
||||
sourcePageId: page.id,
|
||||
workspaceId: fileTask.workspaceId,
|
||||
});
|
||||
|
||||
const pmState = getProsemirrorContent(
|
||||
await this.importService.processHTML(html),
|
||||
);
|
||||
|
||||
const { title, prosemirrorJson } =
|
||||
this.importService.extractTitleAndRemoveHeading(pmState);
|
||||
|
||||
const insertablePage: InsertablePage = {
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
title: title || page.name,
|
||||
content: prosemirrorJson,
|
||||
textContent: jsonToText(prosemirrorJson),
|
||||
ydoc: await this.importService.createYdoc(prosemirrorJson),
|
||||
position: page.position!,
|
||||
spaceId: fileTask.spaceId,
|
||||
workspaceId: fileTask.workspaceId,
|
||||
creatorId: fileTask.creatorId,
|
||||
lastUpdatedById: fileTask.creatorId,
|
||||
parentPageId: page.parentPageId,
|
||||
};
|
||||
|
||||
return { insertablePage, backlinks };
|
||||
}),
|
||||
);
|
||||
|
||||
const insertablePages = pageResults.map((r) => r.insertablePage);
|
||||
const insertableBacklinks = pageResults.flatMap((r) => r.backlinks);
|
||||
|
||||
if (insertablePages.length < 1) return;
|
||||
const validPageIds = new Set(insertablePages.map((row) => row.id));
|
||||
const filteredBacklinks = insertableBacklinks.filter(
|
||||
({ sourcePageId, targetPageId }) =>
|
||||
validPageIds.has(sourcePageId) && validPageIds.has(targetPageId),
|
||||
);
|
||||
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await trx.insertInto('pages').values(insertablePages).execute();
|
||||
|
||||
if (filteredBacklinks.length > 0) {
|
||||
await this.backlinkRepo.insertBacklink(filteredBacklinks, trx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getFileTask(fileTaskId: string) {
|
||||
return this.db
|
||||
.selectFrom('fileTasks')
|
||||
.selectAll()
|
||||
.where('id', '=', fileTaskId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateTaskStatus(
|
||||
fileTaskId: string,
|
||||
status: FileTaskStatus,
|
||||
errorMessage?: string,
|
||||
) {
|
||||
try {
|
||||
await this.db
|
||||
.updateTable('fileTasks')
|
||||
.set({ status: status, errorMessage, updatedAt: new Date() })
|
||||
.where('id', '=', fileTaskId)
|
||||
.execute();
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,303 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as path from 'path';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { cleanUrlString } from '../utils/file.utils';
|
||||
import { StorageService } from '../../storage/storage.service';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { promises as fs } from 'fs';
|
||||
import { getMimeType, sanitizeFileName } from '../../../common/helpers';
|
||||
import { v7 } from 'uuid';
|
||||
import { FileTask } from '@docmost/db/types/entity.types';
|
||||
import { getAttachmentFolderPath } from '../../../core/attachment/attachment.utils';
|
||||
import { AttachmentType } from '../../../core/attachment/attachment.constants';
|
||||
import { unwrapFromParagraph } from '../utils/import-formatter';
|
||||
import { resolveRelativeAttachmentPath } from '../utils/import.utils';
|
||||
import { load } from 'cheerio';
|
||||
|
||||
@Injectable()
|
||||
export class ImportAttachmentService {
|
||||
private readonly logger = new Logger(ImportAttachmentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly storageService: StorageService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
async processAttachments(opts: {
|
||||
html: string;
|
||||
pageRelativePath: string;
|
||||
extractDir: string;
|
||||
pageId: string;
|
||||
fileTask: FileTask;
|
||||
attachmentCandidates: Map<string, string>;
|
||||
}): Promise<string> {
|
||||
const {
|
||||
html,
|
||||
pageRelativePath,
|
||||
extractDir,
|
||||
pageId,
|
||||
fileTask,
|
||||
attachmentCandidates,
|
||||
} = opts;
|
||||
|
||||
const attachmentTasks: Promise<void>[] = [];
|
||||
|
||||
/**
|
||||
* Cache keyed by the *relative* path that appears in the HTML.
|
||||
* Ensures we upload (and DB-insert) each attachment at most once,
|
||||
* even if it’s referenced multiple times on the page.
|
||||
*/
|
||||
const processed = new Map<
|
||||
string,
|
||||
{
|
||||
attachmentId: string;
|
||||
storageFilePath: string;
|
||||
apiFilePath: string;
|
||||
fileNameWithExt: string;
|
||||
abs: string;
|
||||
}
|
||||
>();
|
||||
|
||||
const uploadOnce = (relPath: string) => {
|
||||
const abs = attachmentCandidates.get(relPath)!;
|
||||
const attachmentId = v7();
|
||||
const ext = path.extname(abs);
|
||||
|
||||
const fileNameWithExt =
|
||||
sanitizeFileName(path.basename(abs, ext)) + ext.toLowerCase();
|
||||
|
||||
const storageFilePath = `${getAttachmentFolderPath(
|
||||
AttachmentType.File,
|
||||
fileTask.workspaceId,
|
||||
)}/${attachmentId}/${fileNameWithExt}`;
|
||||
|
||||
const apiFilePath = `/api/files/${attachmentId}/${fileNameWithExt}`;
|
||||
|
||||
attachmentTasks.push(
|
||||
(async () => {
|
||||
const fileStream = createReadStream(abs);
|
||||
await this.storageService.uploadStream(storageFilePath, fileStream);
|
||||
const stat = await fs.stat(abs);
|
||||
|
||||
await this.db
|
||||
.insertInto('attachments')
|
||||
.values({
|
||||
id: attachmentId,
|
||||
filePath: storageFilePath,
|
||||
fileName: fileNameWithExt,
|
||||
fileSize: stat.size,
|
||||
mimeType: getMimeType(fileNameWithExt),
|
||||
type: 'file',
|
||||
fileExt: ext,
|
||||
creatorId: fileTask.creatorId,
|
||||
workspaceId: fileTask.workspaceId,
|
||||
pageId,
|
||||
spaceId: fileTask.spaceId,
|
||||
})
|
||||
.execute();
|
||||
})(),
|
||||
);
|
||||
|
||||
return {
|
||||
attachmentId,
|
||||
storageFilePath,
|
||||
apiFilePath,
|
||||
fileNameWithExt,
|
||||
abs,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* – Returns cached data if we’ve already processed this path.
|
||||
* – Otherwise calls `uploadOnce`, stores the result, and returns it.
|
||||
*/
|
||||
const processFile = (relPath: string) => {
|
||||
const cached = processed.get(relPath);
|
||||
if (cached) return cached;
|
||||
|
||||
const fresh = uploadOnce(relPath);
|
||||
processed.set(relPath, fresh);
|
||||
return fresh;
|
||||
};
|
||||
|
||||
const pageDir = path.dirname(pageRelativePath);
|
||||
const $ = load(html);
|
||||
|
||||
// image
|
||||
for (const imgEl of $('img').toArray()) {
|
||||
const $img = $(imgEl);
|
||||
const src = cleanUrlString($img.attr('src') ?? '')!;
|
||||
if (!src || src.startsWith('http')) continue;
|
||||
|
||||
const relPath = resolveRelativeAttachmentPath(
|
||||
src,
|
||||
pageDir,
|
||||
attachmentCandidates,
|
||||
);
|
||||
if (!relPath) continue;
|
||||
|
||||
const { attachmentId, apiFilePath, abs } = processFile(relPath);
|
||||
const stat = await fs.stat(abs);
|
||||
|
||||
const width = $img.attr('width') ?? '100%';
|
||||
const align = $img.attr('data-align') ?? 'center';
|
||||
|
||||
$img
|
||||
.attr('src', apiFilePath)
|
||||
.attr('data-attachment-id', attachmentId)
|
||||
.attr('data-size', stat.size.toString())
|
||||
.attr('width', width)
|
||||
.attr('data-align', align);
|
||||
|
||||
unwrapFromParagraph($, $img);
|
||||
}
|
||||
|
||||
// video
|
||||
for (const vidEl of $('video').toArray()) {
|
||||
const $vid = $(vidEl);
|
||||
const src = cleanUrlString($vid.attr('src') ?? '')!;
|
||||
if (!src || src.startsWith('http')) continue;
|
||||
|
||||
const relPath = resolveRelativeAttachmentPath(
|
||||
src,
|
||||
pageDir,
|
||||
attachmentCandidates,
|
||||
);
|
||||
if (!relPath) continue;
|
||||
|
||||
const { attachmentId, apiFilePath, abs } = processFile(relPath);
|
||||
const stat = await fs.stat(abs);
|
||||
|
||||
const width = $vid.attr('width') ?? '100%';
|
||||
const align = $vid.attr('data-align') ?? 'center';
|
||||
|
||||
$vid
|
||||
.attr('src', apiFilePath)
|
||||
.attr('data-attachment-id', attachmentId)
|
||||
.attr('data-size', stat.size.toString())
|
||||
.attr('width', width)
|
||||
.attr('data-align', align);
|
||||
|
||||
unwrapFromParagraph($, $vid);
|
||||
}
|
||||
|
||||
// <div data-type="attachment">
|
||||
for (const el of $('div[data-type="attachment"]').toArray()) {
|
||||
const $oldDiv = $(el);
|
||||
const rawUrl = cleanUrlString($oldDiv.attr('data-attachment-url') ?? '')!;
|
||||
if (!rawUrl || rawUrl.startsWith('http')) continue;
|
||||
|
||||
const relPath = resolveRelativeAttachmentPath(
|
||||
rawUrl,
|
||||
pageDir,
|
||||
attachmentCandidates,
|
||||
);
|
||||
if (!relPath) continue;
|
||||
|
||||
const { attachmentId, apiFilePath, abs } = processFile(relPath);
|
||||
const stat = await fs.stat(abs);
|
||||
const fileName = path.basename(abs);
|
||||
const mime = getMimeType(abs);
|
||||
|
||||
const $newDiv = $('<div>')
|
||||
.attr('data-type', 'attachment')
|
||||
.attr('data-attachment-url', apiFilePath)
|
||||
.attr('data-attachment-name', fileName)
|
||||
.attr('data-attachment-mime', mime)
|
||||
.attr('data-attachment-size', stat.size.toString())
|
||||
.attr('data-attachment-id', attachmentId);
|
||||
|
||||
$oldDiv.replaceWith($newDiv);
|
||||
unwrapFromParagraph($, $newDiv);
|
||||
}
|
||||
|
||||
// rewrite other attachments via <a>
|
||||
for (const aEl of $('a').toArray()) {
|
||||
const $a = $(aEl);
|
||||
const href = cleanUrlString($a.attr('href') ?? '')!;
|
||||
if (!href || href.startsWith('http')) continue;
|
||||
|
||||
const relPath = resolveRelativeAttachmentPath(
|
||||
href,
|
||||
pageDir,
|
||||
attachmentCandidates,
|
||||
);
|
||||
if (!relPath) continue;
|
||||
|
||||
const { attachmentId, apiFilePath, abs } = processFile(relPath);
|
||||
const stat = await fs.stat(abs);
|
||||
const ext = path.extname(relPath).toLowerCase();
|
||||
|
||||
if (ext === '.mp4') {
|
||||
const $video = $('<video>')
|
||||
.attr('src', apiFilePath)
|
||||
.attr('data-attachment-id', attachmentId)
|
||||
.attr('data-size', stat.size.toString())
|
||||
.attr('width', '100%')
|
||||
.attr('data-align', 'center');
|
||||
$a.replaceWith($video);
|
||||
unwrapFromParagraph($, $video);
|
||||
} else {
|
||||
const confAliasName = $a.attr('data-linked-resource-default-alias');
|
||||
let attachmentName = path.basename(abs);
|
||||
if (confAliasName) attachmentName = confAliasName;
|
||||
|
||||
const $div = $('<div>')
|
||||
.attr('data-type', 'attachment')
|
||||
.attr('data-attachment-url', apiFilePath)
|
||||
.attr('data-attachment-name', attachmentName)
|
||||
.attr('data-attachment-mime', getMimeType(abs))
|
||||
.attr('data-attachment-size', stat.size.toString())
|
||||
.attr('data-attachment-id', attachmentId);
|
||||
|
||||
$a.replaceWith($div);
|
||||
unwrapFromParagraph($, $div);
|
||||
}
|
||||
}
|
||||
|
||||
// excalidraw and drawio
|
||||
for (const type of ['excalidraw', 'drawio'] as const) {
|
||||
for (const el of $(`div[data-type="${type}"]`).toArray()) {
|
||||
const $oldDiv = $(el);
|
||||
const rawSrc = cleanUrlString($oldDiv.attr('data-src') ?? '')!;
|
||||
if (!rawSrc || rawSrc.startsWith('http')) continue;
|
||||
|
||||
const relPath = resolveRelativeAttachmentPath(
|
||||
rawSrc,
|
||||
pageDir,
|
||||
attachmentCandidates,
|
||||
);
|
||||
if (!relPath) continue;
|
||||
|
||||
const { attachmentId, apiFilePath, abs } = processFile(relPath);
|
||||
const stat = await fs.stat(abs);
|
||||
const fileName = path.basename(abs);
|
||||
|
||||
const width = $oldDiv.attr('data-width') || '100%';
|
||||
const align = $oldDiv.attr('data-align') || 'center';
|
||||
|
||||
const $newDiv = $('<div>')
|
||||
.attr('data-type', type)
|
||||
.attr('data-src', apiFilePath)
|
||||
.attr('data-title', fileName)
|
||||
.attr('data-width', width)
|
||||
.attr('data-size', stat.size.toString())
|
||||
.attr('data-align', align)
|
||||
.attr('data-attachment-id', attachmentId);
|
||||
|
||||
$oldDiv.replaceWith($newDiv);
|
||||
unwrapFromParagraph($, $newDiv);
|
||||
}
|
||||
}
|
||||
|
||||
// wait for all uploads & DB inserts
|
||||
try {
|
||||
await Promise.all(attachmentTasks);
|
||||
} catch (err) {
|
||||
this.logger.log('Import attachment upload error', err);
|
||||
}
|
||||
|
||||
return $.root().html() || '';
|
||||
}
|
||||
}
|
||||
@ -4,16 +4,27 @@ import { MultipartFile } from '@fastify/multipart';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
htmlToJson, jsonToText,
|
||||
htmlToJson,
|
||||
jsonToText,
|
||||
tiptapExtensions,
|
||||
} from '../../collaboration/collaboration.util';
|
||||
} from '../../../collaboration/collaboration.util';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { generateSlugId } from '../../common/helpers';
|
||||
import { generateSlugId, sanitizeFileName } from '../../../common/helpers';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import * as Y from 'yjs';
|
||||
import { markdownToHtml } from "@docmost/editor-ext";
|
||||
import { markdownToHtml } from '@docmost/editor-ext';
|
||||
import {
|
||||
FileTaskStatus,
|
||||
FileTaskType,
|
||||
getFileTaskFolderPath,
|
||||
} from '../utils/file.utils';
|
||||
import { v7 as uuid7 } from 'uuid';
|
||||
import { StorageService } from '../../storage/storage.service';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../../queue/constants';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
@ -21,7 +32,10 @@ export class ImportService {
|
||||
|
||||
constructor(
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly storageService: StorageService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.FILE_TASK_QUEUE)
|
||||
private readonly fileTaskQueue: Queue,
|
||||
) {}
|
||||
|
||||
async importPage(
|
||||
@ -113,7 +127,7 @@ export class ImportService {
|
||||
|
||||
async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
|
||||
if (prosemirrorJson) {
|
||||
this.logger.debug(`Converting prosemirror json state to ydoc`);
|
||||
// this.logger.debug(`Converting prosemirror json state to ydoc`);
|
||||
|
||||
const ydoc = TiptapTransformer.toYdoc(
|
||||
prosemirrorJson,
|
||||
@ -129,20 +143,34 @@ export class ImportService {
|
||||
}
|
||||
|
||||
extractTitleAndRemoveHeading(prosemirrorState: any) {
|
||||
let title = null;
|
||||
let title: string | null = null;
|
||||
|
||||
const content = prosemirrorState.content ?? [];
|
||||
|
||||
if (
|
||||
prosemirrorState?.content?.length > 0 &&
|
||||
prosemirrorState.content[0].type === 'heading' &&
|
||||
prosemirrorState.content[0].attrs?.level === 1
|
||||
content.length > 0 &&
|
||||
content[0].type === 'heading' &&
|
||||
content[0].attrs?.level === 1
|
||||
) {
|
||||
title = prosemirrorState.content[0].content[0].text;
|
||||
|
||||
// remove h1 header node from state
|
||||
prosemirrorState.content.shift();
|
||||
title = content[0].content?.[0]?.text ?? null;
|
||||
content.shift();
|
||||
}
|
||||
|
||||
return { title, prosemirrorJson: prosemirrorState };
|
||||
// ensure at least one paragraph
|
||||
if (content.length === 0) {
|
||||
content.push({
|
||||
type: 'paragraph',
|
||||
content: [],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
prosemirrorJson: {
|
||||
...prosemirrorState,
|
||||
content,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getNewPagePosition(spaceId: string): Promise<string> {
|
||||
@ -161,4 +189,52 @@ export class ImportService {
|
||||
return generateJitteredKeyBetween(null, null);
|
||||
}
|
||||
}
|
||||
|
||||
async importZip(
|
||||
filePromise: Promise<MultipartFile>,
|
||||
source: string,
|
||||
userId: string,
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
) {
|
||||
const file = await filePromise;
|
||||
const fileBuffer = await file.toBuffer();
|
||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||
const fileName = sanitizeFileName(
|
||||
path.basename(file.filename, fileExtension),
|
||||
);
|
||||
const fileSize = fileBuffer.length;
|
||||
|
||||
const fileNameWithExt = fileName + fileExtension;
|
||||
|
||||
const fileTaskId = uuid7();
|
||||
const filePath = `${getFileTaskFolderPath(FileTaskType.Import, workspaceId)}/${fileTaskId}/${fileNameWithExt}`;
|
||||
|
||||
// upload file
|
||||
await this.storageService.upload(filePath, fileBuffer);
|
||||
|
||||
const fileTask = await this.db
|
||||
.insertInto('fileTasks')
|
||||
.values({
|
||||
id: fileTaskId,
|
||||
type: FileTaskType.Import,
|
||||
source: source,
|
||||
status: FileTaskStatus.Processing,
|
||||
fileName: fileNameWithExt,
|
||||
filePath: filePath,
|
||||
fileSize: fileSize,
|
||||
fileExt: 'zip',
|
||||
creatorId: userId,
|
||||
spaceId: spaceId,
|
||||
workspaceId: workspaceId,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
|
||||
await this.fileTaskQueue.add(QueueJob.IMPORT_TASK, {
|
||||
fileTaskId: fileTaskId,
|
||||
});
|
||||
|
||||
return fileTask;
|
||||
}
|
||||
}
|
||||
187
apps/server/src/integrations/import/utils/file.utils.ts
Normal file
187
apps/server/src/integrations/import/utils/file.utils.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import * as yauzl from 'yauzl';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
export enum FileTaskType {
|
||||
Import = 'import',
|
||||
Export = 'export',
|
||||
}
|
||||
|
||||
export enum FileImportSource {
|
||||
Generic = 'generic',
|
||||
Notion = 'notion',
|
||||
Confluence = 'confluence',
|
||||
}
|
||||
|
||||
export enum FileTaskStatus {
|
||||
Processing = 'processing',
|
||||
Success = 'success',
|
||||
Failed = 'failed',
|
||||
}
|
||||
|
||||
export function getFileTaskFolderPath(
|
||||
type: FileTaskType,
|
||||
workspaceId: string,
|
||||
): string {
|
||||
switch (type) {
|
||||
case FileTaskType.Import:
|
||||
return `${workspaceId}/imports`;
|
||||
case FileTaskType.Export:
|
||||
return `${workspaceId}/exports`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a ZIP archive.
|
||||
*/
|
||||
export async function extractZip(
|
||||
source: string,
|
||||
target: string,
|
||||
): Promise<void> {
|
||||
return extractZipInternal(source, target, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to extract a ZIP, with optional single-nested-ZIP handling.
|
||||
* @param source Path to the ZIP file
|
||||
* @param target Directory to extract into
|
||||
* @param allowNested Whether to check and unwrap one level of nested ZIP
|
||||
*/
|
||||
function extractZipInternal(
|
||||
source: string,
|
||||
target: string,
|
||||
allowNested: boolean,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
yauzl.open(
|
||||
source,
|
||||
{ lazyEntries: true, decodeStrings: false, autoClose: true },
|
||||
(err, zipfile) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
// Handle one level of nested ZIP if allowed
|
||||
if (allowNested && zipfile.entryCount === 1) {
|
||||
zipfile.readEntry();
|
||||
zipfile.once('entry', (entry) => {
|
||||
const name = entry.fileName.toString('utf8').replace(/^\/+/, '');
|
||||
const isZip =
|
||||
!/\/$/.test(entry.fileName) &&
|
||||
name.toLowerCase().endsWith('.zip');
|
||||
if (isZip) {
|
||||
// temporary name to avoid overwriting file
|
||||
const nestedPath = source.endsWith('.zip')
|
||||
? source.slice(0, -4) + '.inner.zip'
|
||||
: source + '.inner.zip';
|
||||
|
||||
zipfile.openReadStream(entry, (openErr, rs) => {
|
||||
if (openErr) return reject(openErr);
|
||||
const ws = fs.createWriteStream(nestedPath);
|
||||
rs.on('error', reject);
|
||||
ws.on('error', reject);
|
||||
ws.on('finish', () => {
|
||||
zipfile.close();
|
||||
extractZipInternal(nestedPath, target, false)
|
||||
.then(() => {
|
||||
fs.unlinkSync(nestedPath);
|
||||
resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
rs.pipe(ws);
|
||||
});
|
||||
} else {
|
||||
zipfile.close();
|
||||
extractZipInternal(source, target, false).then(resolve, reject);
|
||||
}
|
||||
});
|
||||
zipfile.once('error', reject);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal extraction
|
||||
zipfile.readEntry();
|
||||
zipfile.on('entry', (entry) => {
|
||||
const name = entry.fileName.toString('utf8');
|
||||
const safe = name.replace(/^\/+/, '');
|
||||
if (safe.startsWith('__MACOSX/')) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = path.join(target, safe);
|
||||
|
||||
// Handle directories
|
||||
if (/\/$/.test(name)) {
|
||||
try {
|
||||
fs.mkdirSync(fullPath, { recursive: true });
|
||||
} catch (mkdirErr: any) {
|
||||
if (mkdirErr.code === 'ENAMETOOLONG') {
|
||||
console.warn(`Skipping directory (path too long): ${fullPath}`);
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
return reject(mkdirErr);
|
||||
}
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle files
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
} catch (mkdirErr: any) {
|
||||
if (mkdirErr.code === 'ENAMETOOLONG') {
|
||||
console.warn(
|
||||
`Skipping file directory creation (path too long): ${fullPath}`,
|
||||
);
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
return reject(mkdirErr);
|
||||
}
|
||||
|
||||
zipfile.openReadStream(entry, (openErr, rs) => {
|
||||
if (openErr) return reject(openErr);
|
||||
|
||||
let ws: fs.WriteStream;
|
||||
try {
|
||||
ws = fs.createWriteStream(fullPath);
|
||||
} catch (openWsErr: any) {
|
||||
if (openWsErr.code === 'ENAMETOOLONG') {
|
||||
console.warn(
|
||||
`Skipping file write (path too long): ${fullPath}`,
|
||||
);
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
return reject(openWsErr);
|
||||
}
|
||||
|
||||
rs.on('error', (err) => reject(err));
|
||||
ws.on('error', (err) => {
|
||||
if ((err as any).code === 'ENAMETOOLONG') {
|
||||
console.warn(
|
||||
`Skipping file write on stream (path too long): ${fullPath}`,
|
||||
);
|
||||
zipfile.readEntry();
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
ws.on('finish', () => zipfile.readEntry());
|
||||
rs.pipe(ws);
|
||||
});
|
||||
});
|
||||
|
||||
zipfile.on('end', () => resolve());
|
||||
zipfile.on('error', (err) => reject(err));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanUrlString(url: string): string {
|
||||
if (!url) return null;
|
||||
const [mainUrl] = url.split('?', 1);
|
||||
return mainUrl;
|
||||
}
|
||||
254
apps/server/src/integrations/import/utils/import-formatter.ts
Normal file
254
apps/server/src/integrations/import/utils/import-formatter.ts
Normal file
@ -0,0 +1,254 @@
|
||||
import { getEmbedUrlAndProvider } from '@docmost/editor-ext';
|
||||
import * as path from 'path';
|
||||
import { v7 } from 'uuid';
|
||||
import { InsertableBacklink } from '@docmost/db/types/entity.types';
|
||||
import { Cheerio, CheerioAPI, load } from 'cheerio';
|
||||
|
||||
export async function formatImportHtml(opts: {
|
||||
html: string;
|
||||
currentFilePath: string;
|
||||
filePathToPageMetaMap: Map<
|
||||
string,
|
||||
{ id: string; title: string; slugId: string }
|
||||
>;
|
||||
creatorId: string;
|
||||
sourcePageId: string;
|
||||
workspaceId: string;
|
||||
pageDir?: string;
|
||||
attachmentCandidates?: string[];
|
||||
}): Promise<{ html: string; backlinks: InsertableBacklink[] }> {
|
||||
const {
|
||||
html,
|
||||
currentFilePath,
|
||||
filePathToPageMetaMap,
|
||||
creatorId,
|
||||
sourcePageId,
|
||||
workspaceId,
|
||||
} = opts;
|
||||
const $: CheerioAPI = load(html);
|
||||
const $root: Cheerio<any> = $.root();
|
||||
|
||||
notionFormatter($, $root);
|
||||
defaultHtmlFormatter($, $root);
|
||||
|
||||
const backlinks = await rewriteInternalLinksToMentionHtml(
|
||||
$,
|
||||
$root,
|
||||
currentFilePath,
|
||||
filePathToPageMetaMap,
|
||||
creatorId,
|
||||
sourcePageId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
html: $root.html() || '',
|
||||
backlinks,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||
$root.find('a[href]').each((_, el) => {
|
||||
const $el = $(el);
|
||||
const url = $el.attr('href')!;
|
||||
const { provider } = getEmbedUrlAndProvider(url);
|
||||
if (provider === 'iframe') return;
|
||||
|
||||
const embed = `<div data-type=\"embed\" data-src=\"${url}\" data-provider=\"${provider}\" data-align=\"center\" data-width=\"640\" data-height=\"480\"></div>`;
|
||||
$el.replaceWith(embed);
|
||||
});
|
||||
|
||||
$root.find('iframe[src]').each((_, el) => {
|
||||
const $el = $(el);
|
||||
const url = $el.attr('src')!;
|
||||
const { provider } = getEmbedUrlAndProvider(url);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
export function notionFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||
// remove empty description paragraphs
|
||||
$root.find('p.page-description').each((_, el) => {
|
||||
if (!$(el).text().trim()) $(el).remove();
|
||||
});
|
||||
|
||||
// block math → mathBlock
|
||||
$root.find('figure.equation').each((_: any, fig: any) => {
|
||||
const $fig = $(fig);
|
||||
const tex = $fig
|
||||
.find('annotation[encoding="application/x-tex"]')
|
||||
.text()
|
||||
.trim();
|
||||
const $math = $('<div>')
|
||||
.attr('data-type', 'mathBlock')
|
||||
.attr('data-katex', 'true')
|
||||
.text(tex);
|
||||
$fig.replaceWith($math);
|
||||
});
|
||||
|
||||
// inline math → mathInline
|
||||
$root.find('span.notion-text-equation-token').each((_, tok) => {
|
||||
const $tok = $(tok);
|
||||
const $prev = $tok.prev('style');
|
||||
if ($prev.length) $prev.remove();
|
||||
const tex = $tok
|
||||
.find('annotation[encoding="application/x-tex"]')
|
||||
.text()
|
||||
.trim();
|
||||
const $inline = $('<span>')
|
||||
.attr('data-type', 'mathInline')
|
||||
.attr('data-katex', 'true')
|
||||
.text(tex);
|
||||
$tok.replaceWith($inline);
|
||||
});
|
||||
|
||||
// callouts
|
||||
$root
|
||||
.find('figure.callout')
|
||||
.get()
|
||||
.reverse()
|
||||
.forEach((fig) => {
|
||||
const $fig = $(fig);
|
||||
const $content = $fig.find('div').eq(1);
|
||||
if (!$content.length) return;
|
||||
const $wrapper = $('<div>')
|
||||
.attr('data-type', 'callout')
|
||||
.attr('data-callout-type', 'info');
|
||||
// @ts-ignore
|
||||
$content.children().each((_, child) => $wrapper.append(child));
|
||||
$fig.replaceWith($wrapper);
|
||||
});
|
||||
|
||||
// to-do lists
|
||||
$root.find('ul.to-do-list').each((_, list) => {
|
||||
const $old = $(list);
|
||||
const $new = $('<ul>').attr('data-type', 'taskList');
|
||||
$old.find('li').each((_, li) => {
|
||||
const $li = $(li);
|
||||
const isChecked = $li.find('.checkbox.checkbox-on').length > 0;
|
||||
const text =
|
||||
$li
|
||||
.find('span.to-do-children-unchecked, span.to-do-children-checked')
|
||||
.first()
|
||||
.text()
|
||||
.trim() || '';
|
||||
const $taskItem = $('<li>')
|
||||
.attr('data-type', 'taskItem')
|
||||
.attr('data-checked', String(isChecked));
|
||||
const $label = $('<label>');
|
||||
const $input = $('<input>').attr('type', 'checkbox');
|
||||
if (isChecked) $input.attr('checked', '');
|
||||
$label.append($input, $('<span>'));
|
||||
const $container = $('<div>').append($('<p>').text(text));
|
||||
$taskItem.append($label, $container);
|
||||
$new.append($taskItem);
|
||||
});
|
||||
$old.replaceWith($new);
|
||||
});
|
||||
|
||||
// toggle blocks
|
||||
$root
|
||||
.find('ul.toggle details')
|
||||
.get()
|
||||
.reverse()
|
||||
.forEach((det) => {
|
||||
const $det = $(det);
|
||||
const $li = $det.closest('li');
|
||||
if ($li.length) {
|
||||
$li.before($det);
|
||||
if (!$li.children().length) $li.remove();
|
||||
}
|
||||
const $ul = $det.closest('ul.toggle');
|
||||
if ($ul.length) {
|
||||
$ul.before($det);
|
||||
if (!$ul.children().length) $ul.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// bookmarks
|
||||
$root
|
||||
.find('figure')
|
||||
.filter((_, fig) => $(fig).find('a.bookmark.source').length > 0)
|
||||
.get()
|
||||
.reverse()
|
||||
.forEach((fig) => {
|
||||
const $fig = $(fig);
|
||||
const $link = $fig.find('a.bookmark.source').first();
|
||||
if (!$link.length) return;
|
||||
|
||||
const href = $link.attr('href')!;
|
||||
const title = $link.find('.bookmark-title').text().trim() || href;
|
||||
|
||||
const $newAnchor = $('<a>')
|
||||
.addClass('bookmark source')
|
||||
.attr('href', href)
|
||||
.append($('<div>').addClass('bookmark-info').text(title));
|
||||
|
||||
$fig.replaceWith($newAnchor);
|
||||
});
|
||||
|
||||
// remove toc
|
||||
$root.find('nav.table_of_contents').remove();
|
||||
}
|
||||
|
||||
export function unwrapFromParagraph($: CheerioAPI, $node: Cheerio<any>) {
|
||||
// find the nearest <p> or <a> ancestor
|
||||
let $wrapper = $node.closest('p, a');
|
||||
|
||||
while ($wrapper.length) {
|
||||
// if the wrapper has only our node inside, replace it entirely
|
||||
if ($wrapper.contents().length === 1) {
|
||||
$wrapper.replaceWith($node);
|
||||
} else {
|
||||
// otherwise just move the node to before the wrapper
|
||||
$wrapper.before($node);
|
||||
}
|
||||
// look again for any new wrapper around $node
|
||||
$wrapper = $node.closest('p, a');
|
||||
}
|
||||
}
|
||||
|
||||
export async function rewriteInternalLinksToMentionHtml(
|
||||
$: CheerioAPI,
|
||||
$root: Cheerio<any>,
|
||||
currentFilePath: string,
|
||||
filePathToPageMetaMap: Map<
|
||||
string,
|
||||
{ id: string; title: string; slugId: string }
|
||||
>,
|
||||
creatorId: string,
|
||||
sourcePageId: string,
|
||||
workspaceId: string,
|
||||
): Promise<InsertableBacklink[]> {
|
||||
const normalize = (p: string) => p.replace(/\\/g, '/');
|
||||
const backlinks: InsertableBacklink[] = [];
|
||||
|
||||
$root.find('a[href]').each((_, el) => {
|
||||
const $a = $(el);
|
||||
const raw = $a.attr('href')!;
|
||||
if (raw.startsWith('http') || raw.startsWith('/api/')) return;
|
||||
const resolved = normalize(
|
||||
path.join(path.dirname(currentFilePath), decodeURIComponent(raw)),
|
||||
);
|
||||
const meta = filePathToPageMetaMap.get(resolved);
|
||||
if (!meta) return;
|
||||
const mentionId = v7();
|
||||
const $mention = $('<span>')
|
||||
.attr({
|
||||
'data-type': 'mention',
|
||||
'data-id': mentionId,
|
||||
'data-entity-type': 'page',
|
||||
'data-entity-id': meta.id,
|
||||
'data-label': meta.title,
|
||||
'data-slug-id': meta.slugId,
|
||||
'data-creator-id': creatorId,
|
||||
})
|
||||
.text(meta.title);
|
||||
$a.replaceWith($mention);
|
||||
backlinks.push({ sourcePageId, targetPageId: meta.id, workspaceId });
|
||||
});
|
||||
|
||||
return backlinks;
|
||||
}
|
||||
66
apps/server/src/integrations/import/utils/import.utils.ts
Normal file
66
apps/server/src/integrations/import/utils/import.utils.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export async function buildAttachmentCandidates(
|
||||
extractDir: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const map = new Map<string, string>();
|
||||
async function walk(dir: string) {
|
||||
for (const ent of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const abs = path.join(dir, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
await walk(abs);
|
||||
} else {
|
||||
if (['.md', '.html'].includes(path.extname(ent.name).toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rel = path.relative(extractDir, abs).split(path.sep).join('/');
|
||||
map.set(rel, abs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(extractDir);
|
||||
return map;
|
||||
}
|
||||
|
||||
export function resolveRelativeAttachmentPath(
|
||||
raw: string,
|
||||
pageDir: string,
|
||||
attachmentCandidates: Map<string, string>,
|
||||
): string | null {
|
||||
const mainRel = decodeURIComponent(raw.replace(/^\.?\/+/, ''));
|
||||
const fallback = path.normalize(path.join(pageDir, mainRel));
|
||||
|
||||
if (attachmentCandidates.has(mainRel)) {
|
||||
return mainRel;
|
||||
}
|
||||
if (attachmentCandidates.has(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function collectMarkdownAndHtmlFiles(
|
||||
dir: string,
|
||||
): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
|
||||
async function walk(current: string) {
|
||||
const entries = await fs.readdir(current, { withFileTypes: true });
|
||||
for (const ent of entries) {
|
||||
const fullPath = path.join(current, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
await walk(fullPath);
|
||||
} else if (
|
||||
['.md', '.html'].includes(path.extname(ent.name).toLowerCase())
|
||||
) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(dir);
|
||||
return results;
|
||||
}
|
||||
@ -3,6 +3,7 @@ export enum QueueName {
|
||||
ATTACHMENT_QUEUE = '{attachment-queue}',
|
||||
GENERAL_QUEUE = '{general-queue}',
|
||||
BILLING_QUEUE = '{billing-queue}',
|
||||
FILE_TASK_QUEUE = '{file-task-queue}',
|
||||
}
|
||||
|
||||
export enum QueueJob {
|
||||
@ -19,4 +20,7 @@ export enum QueueJob {
|
||||
TRIAL_ENDED = 'trial-ended',
|
||||
WELCOME_EMAIL = 'welcome-email',
|
||||
FIRST_PAYMENT_EMAIL = 'first-payment-email',
|
||||
|
||||
IMPORT_TASK = 'import-task',
|
||||
EXPORT_TASK = 'export-task',
|
||||
}
|
||||
|
||||
@ -49,6 +49,14 @@ import { BacklinksProcessor } from './processors/backlinks.processor';
|
||||
BullModule.registerQueue({
|
||||
name: QueueName.BILLING_QUEUE,
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: QueueName.FILE_TASK_QUEUE,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
attempts: 1,
|
||||
},
|
||||
}),
|
||||
],
|
||||
exports: [BullModule],
|
||||
providers: [BacklinksProcessor],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user