mirror of
https://github.com/docmost/docmost.git
synced 2026-05-13 02:34:05 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25fce8b049 | |||
| 8522844673 | |||
| f8dc9845a7 | |||
| 4dfed2b2af |
@@ -222,7 +222,9 @@
|
|||||||
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
||||||
"Invite link": "Invite link",
|
"Invite link": "Invite link",
|
||||||
"Copy": "Copy",
|
"Copy": "Copy",
|
||||||
|
"Copy to space": "Copy to space",
|
||||||
"Copied": "Copied",
|
"Copied": "Copied",
|
||||||
|
"Duplicate": "Duplicate",
|
||||||
"Select a user": "Select a user",
|
"Select a user": "Select a user",
|
||||||
"Select a group": "Select a group",
|
"Select a group": "Select a group",
|
||||||
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
||||||
@@ -390,6 +392,7 @@
|
|||||||
"Copy page": "Copy page",
|
"Copy page": "Copy page",
|
||||||
"Copy page to a different space.": "Copy page to a different space.",
|
"Copy page to a different space.": "Copy page to a different space.",
|
||||||
"Page copied successfully": "Page copied successfully",
|
"Page copied successfully": "Page copied successfully",
|
||||||
|
"Page duplicated successfully": "Page duplicated successfully",
|
||||||
"Find": "Find",
|
"Find": "Find",
|
||||||
"Not found": "Not found",
|
"Not found": "Not found",
|
||||||
"Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)",
|
"Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)",
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing {
|
||||||
|
user-select: none;
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 10;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizeHandleBottom {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 24px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2;
|
||||||
|
touch-action: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.05)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@mixin light {
|
||||||
|
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper:hover .resizeHandleBottom,
|
||||||
|
.resizing .resizeHandleBottom {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizeBar {
|
||||||
|
width: 50px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-gray-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-gray-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizeHandleBottom:hover .resizeBar,
|
||||||
|
.resizing .resizeBar {
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-gray-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-gray-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import classes from "./resizable-wrapper.module.css";
|
||||||
|
|
||||||
|
interface ResizableWrapperProps {
|
||||||
|
children: ReactNode;
|
||||||
|
initialHeight?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
onResize?: (height: number) => void;
|
||||||
|
isEditable?: boolean;
|
||||||
|
className?: string;
|
||||||
|
showHandles?: "always" | "hover";
|
||||||
|
direction?: "vertical" | "horizontal" | "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
|
||||||
|
children,
|
||||||
|
initialHeight = 480,
|
||||||
|
minHeight = 200,
|
||||||
|
maxHeight = 1200,
|
||||||
|
onResize,
|
||||||
|
isEditable = true,
|
||||||
|
className,
|
||||||
|
showHandles = "hover",
|
||||||
|
direction = "vertical",
|
||||||
|
}) => {
|
||||||
|
const [resizeParams, setResizeParams] = useState<{
|
||||||
|
initialSize: number;
|
||||||
|
initialClientY: number;
|
||||||
|
initialClientX: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [currentHeight, setCurrentHeight] = useState(initialHeight);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resizeParams) return;
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!wrapperRef.current) return;
|
||||||
|
|
||||||
|
if (direction === "vertical" || direction === "both") {
|
||||||
|
const deltaY = e.clientY - resizeParams.initialClientY;
|
||||||
|
const newHeight = Math.min(
|
||||||
|
Math.max(resizeParams.initialSize + deltaY, minHeight),
|
||||||
|
maxHeight
|
||||||
|
);
|
||||||
|
setCurrentHeight(newHeight);
|
||||||
|
wrapperRef.current.style.height = `${newHeight}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setResizeParams(null);
|
||||||
|
if (onResize && currentHeight !== initialHeight) {
|
||||||
|
onResize(currentHeight);
|
||||||
|
}
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]);
|
||||||
|
|
||||||
|
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setResizeParams({
|
||||||
|
initialSize: currentHeight,
|
||||||
|
initialClientY: e.clientY,
|
||||||
|
initialClientX: e.clientX,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.style.cursor = "ns-resize";
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
}, [currentHeight]);
|
||||||
|
|
||||||
|
const shouldShowHandles =
|
||||||
|
isEditable &&
|
||||||
|
(showHandles === "always" || (showHandles === "hover" && (isHovered || resizeParams)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className={clsx(classes.wrapper, className, {
|
||||||
|
[classes.resizing]: !!resizeParams,
|
||||||
|
})}
|
||||||
|
style={{ height: currentHeight }}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{!!resizeParams && <div className={classes.overlay} />}
|
||||||
|
{shouldShowHandles && direction === "vertical" && (
|
||||||
|
<div
|
||||||
|
className={classes.resizeHandleBottom}
|
||||||
|
onMouseDown={handleResizeStart}
|
||||||
|
>
|
||||||
|
<div className={classes.resizeBar} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
.embedWrapper {
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-gray-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.embedIframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { useMemo } from "react";
|
import React, { useMemo, useCallback } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
AspectRatio,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
FocusTrap,
|
FocusTrap,
|
||||||
@@ -14,7 +13,8 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
|
import { zodResolver } from "mantine-form-zod-resolver";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
getEmbedProviderById,
|
getEmbedProviderById,
|
||||||
getEmbedUrlAndProvider,
|
getEmbedUrlAndProvider,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
|
import { ResizableWrapper } from "../common/resizable-wrapper";
|
||||||
|
import classes from "./embed-view.module.css";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
url: z
|
url: z
|
||||||
@@ -33,7 +35,7 @@ const schema = z.object({
|
|||||||
export default function EmbedView(props: NodeViewProps) {
|
export default function EmbedView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, selected, updateAttributes, editor } = props;
|
const { node, selected, updateAttributes, editor } = props;
|
||||||
const { src, provider } = node.attrs;
|
const { src, provider, height: nodeHeight } = node.attrs;
|
||||||
|
|
||||||
const embedUrl = useMemo(() => {
|
const embedUrl = useMemo(() => {
|
||||||
if (src) {
|
if (src) {
|
||||||
@@ -49,6 +51,10 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
validate: zodResolver(schema),
|
validate: zodResolver(schema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleResize = useCallback((newHeight: number) => {
|
||||||
|
updateAttributes({ height: newHeight });
|
||||||
|
}, [updateAttributes]);
|
||||||
|
|
||||||
async function onSubmit(data: { url: string }) {
|
async function onSubmit(data: { url: string }) {
|
||||||
if (!editor.isEditable) {
|
if (!editor.isEditable) {
|
||||||
return;
|
return;
|
||||||
@@ -77,17 +83,25 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
{embedUrl ? (
|
{embedUrl ? (
|
||||||
<>
|
<ResizableWrapper
|
||||||
<AspectRatio ratio={16 / 9}>
|
initialHeight={nodeHeight || 480}
|
||||||
<iframe
|
minHeight={200}
|
||||||
src={embedUrl}
|
maxHeight={1200}
|
||||||
allow="encrypted-media"
|
onResize={handleResize}
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
isEditable={editor.isEditable}
|
||||||
allowFullScreen
|
className={clsx(classes.embedWrapper, {
|
||||||
frameBorder="0"
|
"ProseMirror-selectednode": selected,
|
||||||
></iframe>
|
})}
|
||||||
</AspectRatio>
|
>
|
||||||
</>
|
<iframe
|
||||||
|
className={classes.embedIframe}
|
||||||
|
src={embedUrl}
|
||||||
|
allow="encrypted-media"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||||
|
allowFullScreen
|
||||||
|
frameBorder="0"
|
||||||
|
/>
|
||||||
|
</ResizableWrapper>
|
||||||
) : (
|
) : (
|
||||||
<Popover
|
<Popover
|
||||||
width={300}
|
width={300}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Modal, Button, Group, Text } from "@mantine/core";
|
import { Modal, Button, Group, Text } from "@mantine/core";
|
||||||
import { copyPageToSpace } from "@/features/page/services/page-service.ts";
|
import { duplicatePage } from "@/features/page/services/page-service.ts";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -30,7 +30,7 @@ export default function CopyPageModal({
|
|||||||
if (!targetSpace) return;
|
if (!targetSpace) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const copiedPage = await copyPageToSpace({
|
const copiedPage = await duplicatePage({
|
||||||
pageId,
|
pageId,
|
||||||
spaceId: targetSpace.id,
|
spaceId: targetSpace.id,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
|
|||||||
await api.post<void>("/pages/move-to-space", data);
|
await api.post<void>("/pages/move-to-space", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyPageToSpace(data: ICopyPageToSpace): Promise<IPage> {
|
export async function duplicatePage(data: ICopyPageToSpace): Promise<IPage> {
|
||||||
const req = await api.post<IPage>("/pages/copy-to-space", data);
|
const req = await api.post<IPage>("/pages/duplicate", data);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
import {
|
||||||
|
NodeApi,
|
||||||
|
NodeRendererProps,
|
||||||
|
Tree,
|
||||||
|
TreeApi,
|
||||||
|
SimpleTree,
|
||||||
|
} from "react-arborist";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||||
import {
|
import {
|
||||||
@@ -66,6 +72,7 @@ import MovePageModal from "../../components/move-page-modal.tsx";
|
|||||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
||||||
|
import { duplicatePage } from "../../services/page-service.ts";
|
||||||
|
|
||||||
interface SpaceTreeProps {
|
interface SpaceTreeProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -90,8 +97,14 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
|
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
|
||||||
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
||||||
const rootElement = useRef<HTMLDivElement>();
|
const rootElement = useRef<HTMLDivElement>();
|
||||||
|
const [isRootReady, setIsRootReady] = useState(false);
|
||||||
const { ref: sizeRef, width, height } = useElementSize();
|
const { ref: sizeRef, width, height } = useElementSize();
|
||||||
const mergedRef = useMergedRef(rootElement, sizeRef);
|
const mergedRef = useMergedRef((element) => {
|
||||||
|
rootElement.current = element;
|
||||||
|
if (element && !isRootReady) {
|
||||||
|
setIsRootReady(true);
|
||||||
|
}
|
||||||
|
}, sizeRef);
|
||||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||||
const { data: currentPage } = usePageQuery({
|
const { data: currentPage } = usePageQuery({
|
||||||
pageId: extractPageSlugId(pageSlug),
|
pageId: extractPageSlugId(pageSlug),
|
||||||
@@ -199,16 +212,17 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
}
|
}
|
||||||
}, [currentPage?.id]);
|
}, [currentPage?.id]);
|
||||||
|
|
||||||
|
// Clean up tree API on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (treeApiRef.current) {
|
return () => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
setTreeApi(treeApiRef.current);
|
setTreeApi(null);
|
||||||
}
|
};
|
||||||
}, [treeApiRef.current]);
|
}, [setTreeApi]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={mergedRef} className={classes.treeContainer}>
|
<div ref={mergedRef} className={classes.treeContainer}>
|
||||||
{rootElement.current && (
|
{isRootReady && rootElement.current && (
|
||||||
<Tree
|
<Tree
|
||||||
data={data.filter((node) => node?.spaceId === spaceId)}
|
data={data.filter((node) => node?.spaceId === spaceId)}
|
||||||
disableDrag={readOnly}
|
disableDrag={readOnly}
|
||||||
@@ -217,7 +231,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
{...controllers}
|
{...controllers}
|
||||||
width={width}
|
width={width}
|
||||||
height={rootElement.current.clientHeight}
|
height={rootElement.current.clientHeight}
|
||||||
ref={treeApiRef}
|
ref={(ref) => {
|
||||||
|
treeApiRef.current = ref;
|
||||||
|
if (ref) {
|
||||||
|
//@ts-ignore
|
||||||
|
setTreeApi(ref);
|
||||||
|
}
|
||||||
|
}}
|
||||||
openByDefault={false}
|
openByDefault={false}
|
||||||
disableMultiSelection={true}
|
disableMultiSelection={true}
|
||||||
className={classes.tree}
|
className={classes.tree}
|
||||||
@@ -383,7 +403,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
||||||
|
|
||||||
<div className={classes.actions}>
|
<div className={classes.actions}>
|
||||||
<NodeMenu node={node} treeApi={tree} />
|
<NodeMenu node={node} treeApi={tree} spaceId={node.data.spaceId} />
|
||||||
|
|
||||||
{!tree.props.disableEdit && (
|
{!tree.props.disableEdit && (
|
||||||
<CreateNode
|
<CreateNode
|
||||||
@@ -436,13 +456,16 @@ function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
|
|||||||
interface NodeMenuProps {
|
interface NodeMenuProps {
|
||||||
node: NodeApi<SpaceTreeNode>;
|
node: NodeApi<SpaceTreeNode>;
|
||||||
treeApi: TreeApi<SpaceTreeNode>;
|
treeApi: TreeApi<SpaceTreeNode>;
|
||||||
|
spaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const clipboard = useClipboard({ timeout: 500 });
|
const clipboard = useClipboard({ timeout: 500 });
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { openDeleteModal } = useDeletePageModal();
|
const { openDeleteModal } = useDeletePageModal();
|
||||||
|
const [data, setData] = useAtom(treeDataAtom);
|
||||||
|
const emit = useQueryEmit();
|
||||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
const [
|
const [
|
||||||
@@ -461,6 +484,68 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
notifications.show({ message: t("Link copied") });
|
notifications.show({ message: t("Link copied") });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDuplicatePage = async () => {
|
||||||
|
try {
|
||||||
|
const duplicatedPage = await duplicatePage({
|
||||||
|
pageId: node.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the index of the current node
|
||||||
|
const parentId =
|
||||||
|
node.parent?.id === "__REACT_ARBORIST_INTERNAL_ROOT__"
|
||||||
|
? null
|
||||||
|
: node.parent?.id;
|
||||||
|
const siblings = parentId ? node.parent.children : treeApi?.props.data;
|
||||||
|
const currentIndex =
|
||||||
|
siblings?.findIndex((sibling) => sibling.id === node.id) || 0;
|
||||||
|
const newIndex = currentIndex + 1;
|
||||||
|
|
||||||
|
// Add the duplicated page to the tree
|
||||||
|
const treeNodeData: SpaceTreeNode = {
|
||||||
|
id: duplicatedPage.id,
|
||||||
|
slugId: duplicatedPage.slugId,
|
||||||
|
name: duplicatedPage.title,
|
||||||
|
position: duplicatedPage.position,
|
||||||
|
spaceId: duplicatedPage.spaceId,
|
||||||
|
parentPageId: duplicatedPage.parentPageId,
|
||||||
|
icon: duplicatedPage.icon,
|
||||||
|
hasChildren: duplicatedPage.hasChildren,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update local tree
|
||||||
|
const simpleTree = new SimpleTree(data);
|
||||||
|
simpleTree.create({
|
||||||
|
parentId,
|
||||||
|
index: newIndex,
|
||||||
|
data: treeNodeData,
|
||||||
|
});
|
||||||
|
setData(simpleTree.data);
|
||||||
|
|
||||||
|
// Emit socket event
|
||||||
|
setTimeout(() => {
|
||||||
|
emit({
|
||||||
|
operation: "addTreeNode",
|
||||||
|
spaceId: spaceId,
|
||||||
|
payload: {
|
||||||
|
parentId,
|
||||||
|
index: newIndex,
|
||||||
|
data: treeNodeData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
message: t("Page duplicated successfully"),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
message: err.response?.data.message || "An error occurred",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu shadow="md" width={200}>
|
<Menu shadow="md" width={200}>
|
||||||
@@ -505,6 +590,17 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
|
|
||||||
{!(treeApi.props.disableEdit as boolean) && (
|
{!(treeApi.props.disableEdit as boolean) && (
|
||||||
<>
|
<>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconCopy size={16} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDuplicatePage();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("Duplicate")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconArrowRight size={16} />}
|
leftSection={<IconArrowRight size={16} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -524,7 +620,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
openCopyPageModal();
|
openCopyPageModal();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("Copy")}
|
{t("Copy to space")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export interface IMovePageToSpace {
|
|||||||
|
|
||||||
export interface ICopyPageToSpace {
|
export interface ICopyPageToSpace {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
spaceId: string;
|
spaceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarPagesParams {
|
export interface SidebarPagesParams {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
"nestjs-kysely": "^1.2.0",
|
"nestjs-kysely": "^1.2.0",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"openid-client": "^5.7.1",
|
"openid-client": "^5.7.1",
|
||||||
|
"p-limit": "^6.2.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pg": "^8.16.0",
|
"pg": "^8.16.0",
|
||||||
|
|||||||
+4
-4
@@ -1,13 +1,13 @@
|
|||||||
import { IsString, IsNotEmpty } from 'class-validator';
|
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class CopyPageToSpaceDto {
|
export class DuplicatePageDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
spaceId: string;
|
spaceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CopyPageMapEntry = {
|
export type CopyPageMapEntry = {
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { RecentPageDto } from './dto/recent-page.dto';
|
import { RecentPageDto } from './dto/recent-page.dto';
|
||||||
import { CopyPageToSpaceDto } from './dto/copy-page.dto';
|
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('pages')
|
@Controller('pages')
|
||||||
@@ -242,33 +242,41 @@ export class PageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('copy-to-space')
|
@Post('duplicate')
|
||||||
async copyPageToSpace(
|
async duplicatePage(@Body() dto: DuplicatePageDto, @AuthUser() user: User) {
|
||||||
@Body() dto: CopyPageToSpaceDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
) {
|
|
||||||
const copiedPage = await this.pageRepo.findById(dto.pageId);
|
const copiedPage = await this.pageRepo.findById(dto.pageId);
|
||||||
if (!copiedPage) {
|
if (!copiedPage) {
|
||||||
throw new NotFoundException('Page to copy not found');
|
throw new NotFoundException('Page to copy not found');
|
||||||
}
|
}
|
||||||
if (copiedPage.spaceId === dto.spaceId) {
|
|
||||||
throw new BadRequestException('Page is already in this space');
|
// If spaceId is provided, it's a copy to different space
|
||||||
|
if (dto.spaceId) {
|
||||||
|
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.duplicatePage(copiedPage, dto.spaceId, user);
|
||||||
|
} else {
|
||||||
|
// If no spaceId, it's a duplicate in same space
|
||||||
|
const ability = await this.spaceAbility.createForUser(
|
||||||
|
user,
|
||||||
|
copiedPage.spaceId,
|
||||||
|
);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.pageService.duplicatePage(copiedPage, undefined, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ import {
|
|||||||
removeMarkTypeFromDoc,
|
removeMarkTypeFromDoc,
|
||||||
} from '../../../common/helpers/prosemirror/utils';
|
} from '../../../common/helpers/prosemirror/utils';
|
||||||
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
|
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
|
||||||
import { CopyPageMapEntry, ICopyPageAttachment } from '../dto/copy-page.dto';
|
import {
|
||||||
|
CopyPageMapEntry,
|
||||||
|
ICopyPageAttachment,
|
||||||
|
} from '../dto/duplicate-page.dto';
|
||||||
import { Node as PMNode } from '@tiptap/pm/model';
|
import { Node as PMNode } from '@tiptap/pm/model';
|
||||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||||
|
|
||||||
@@ -258,11 +261,52 @@ export class PageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async copyPageToSpace(rootPage: Page, spaceId: string, authUser: User) {
|
async duplicatePage(
|
||||||
//TODO:
|
rootPage: Page,
|
||||||
// i. maintain internal links within copied pages
|
targetSpaceId: string | undefined,
|
||||||
|
authUser: User,
|
||||||
|
) {
|
||||||
|
const spaceId = targetSpaceId || rootPage.spaceId;
|
||||||
|
const isDuplicateInSameSpace =
|
||||||
|
!targetSpaceId || targetSpaceId === rootPage.spaceId;
|
||||||
|
|
||||||
const nextPosition = await this.nextPagePosition(spaceId);
|
let nextPosition: string;
|
||||||
|
|
||||||
|
if (isDuplicateInSameSpace) {
|
||||||
|
// For duplicate in same space, position right after the original page
|
||||||
|
let siblingQuery = this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['position'])
|
||||||
|
.where('spaceId', '=', rootPage.spaceId)
|
||||||
|
.where('position', '>', rootPage.position);
|
||||||
|
|
||||||
|
if (rootPage.parentPageId) {
|
||||||
|
siblingQuery = siblingQuery.where(
|
||||||
|
'parentPageId',
|
||||||
|
'=',
|
||||||
|
rootPage.parentPageId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
siblingQuery = siblingQuery.where('parentPageId', 'is', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSibling = await siblingQuery
|
||||||
|
.orderBy('position', 'asc')
|
||||||
|
.limit(1)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (nextSibling) {
|
||||||
|
nextPosition = generateJitteredKeyBetween(
|
||||||
|
rootPage.position,
|
||||||
|
nextSibling.position,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
nextPosition = generateJitteredKeyBetween(rootPage.position, null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For copy to different space, position at the end
|
||||||
|
nextPosition = await this.nextPagePosition(spaceId);
|
||||||
|
}
|
||||||
|
|
||||||
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
||||||
includeContent: true,
|
includeContent: true,
|
||||||
@@ -326,12 +370,38 @@ export class PageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update internal page links in mention nodes
|
||||||
|
prosemirrorDoc.descendants((node: PMNode) => {
|
||||||
|
if (
|
||||||
|
node.type.name === 'mention' &&
|
||||||
|
node.attrs.entityType === 'page'
|
||||||
|
) {
|
||||||
|
const referencedPageId = node.attrs.entityId;
|
||||||
|
|
||||||
|
// Check if the referenced page is within the pages being copied
|
||||||
|
if (referencedPageId && pageMap.has(referencedPageId)) {
|
||||||
|
const mappedPage = pageMap.get(referencedPageId);
|
||||||
|
//@ts-ignore
|
||||||
|
node.attrs.entityId = mappedPage.newPageId;
|
||||||
|
//@ts-ignore
|
||||||
|
node.attrs.slugId = mappedPage.newSlugId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const prosemirrorJson = prosemirrorDoc.toJSON();
|
const prosemirrorJson = prosemirrorDoc.toJSON();
|
||||||
|
|
||||||
|
// Add "Copy of " prefix to the root page title only for duplicates in same space
|
||||||
|
let title = page.title;
|
||||||
|
if (isDuplicateInSameSpace && page.id === rootPage.id) {
|
||||||
|
const originalTitle = page.title || 'Untitled';
|
||||||
|
title = `Copy of ${originalTitle}`;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: pageFromMap.newPageId,
|
id: pageFromMap.newPageId,
|
||||||
slugId: pageFromMap.newSlugId,
|
slugId: pageFromMap.newSlugId,
|
||||||
title: page.title,
|
title: title,
|
||||||
icon: page.icon,
|
icon: page.icon,
|
||||||
content: prosemirrorJson,
|
content: prosemirrorJson,
|
||||||
textContent: jsonToText(prosemirrorJson),
|
textContent: jsonToText(prosemirrorJson),
|
||||||
@@ -401,9 +471,16 @@ export class PageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newPageId = pageMap.get(rootPage.id).newPageId;
|
const newPageId = pageMap.get(rootPage.id).newPageId;
|
||||||
return await this.pageRepo.findById(newPageId, {
|
const duplicatedPage = await this.pageRepo.findById(newPageId, {
|
||||||
includeSpace: true,
|
includeSpace: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasChildren = pages.length > 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...duplicatedPage,
|
||||||
|
hasChildren,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async movePage(dto: MovePageDto, movedPage: Page) {
|
async movePage(dto: MovePageDto, movedPage: Page) {
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ import { AttachmentType } from '../../../core/attachment/attachment.constants';
|
|||||||
import { unwrapFromParagraph } from '../utils/import-formatter';
|
import { unwrapFromParagraph } from '../utils/import-formatter';
|
||||||
import { resolveRelativeAttachmentPath } from '../utils/import.utils';
|
import { resolveRelativeAttachmentPath } from '../utils/import.utils';
|
||||||
import { load } from 'cheerio';
|
import { load } from 'cheerio';
|
||||||
|
import pLimit from 'p-limit';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportAttachmentService {
|
export class ImportAttachmentService {
|
||||||
private readonly logger = new Logger(ImportAttachmentService.name);
|
private readonly logger = new Logger(ImportAttachmentService.name);
|
||||||
|
private readonly CONCURRENT_UPLOADS = 3;
|
||||||
|
private readonly MAX_RETRIES = 2;
|
||||||
|
private readonly RETRY_DELAY = 2000;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly storageService: StorageService,
|
private readonly storageService: StorageService,
|
||||||
@@ -41,7 +45,14 @@ export class ImportAttachmentService {
|
|||||||
attachmentCandidates,
|
attachmentCandidates,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const attachmentTasks: Promise<void>[] = [];
|
const attachmentTasks: (() => Promise<void>)[] = [];
|
||||||
|
const limit = pLimit(this.CONCURRENT_UPLOADS);
|
||||||
|
const uploadStats = {
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0,
|
||||||
|
failedFiles: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache keyed by the *relative* path that appears in the HTML.
|
* Cache keyed by the *relative* path that appears in the HTML.
|
||||||
@@ -74,30 +85,16 @@ export class ImportAttachmentService {
|
|||||||
|
|
||||||
const apiFilePath = `/api/files/${attachmentId}/${fileNameWithExt}`;
|
const apiFilePath = `/api/files/${attachmentId}/${fileNameWithExt}`;
|
||||||
|
|
||||||
attachmentTasks.push(
|
attachmentTasks.push(() => this.uploadWithRetry({
|
||||||
(async () => {
|
abs,
|
||||||
const fileStream = createReadStream(abs);
|
storageFilePath,
|
||||||
await this.storageService.uploadStream(storageFilePath, fileStream);
|
attachmentId,
|
||||||
const stat = await fs.stat(abs);
|
fileNameWithExt,
|
||||||
|
ext,
|
||||||
await this.db
|
pageId,
|
||||||
.insertInto('attachments')
|
fileTask,
|
||||||
.values({
|
uploadStats,
|
||||||
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 {
|
return {
|
||||||
attachmentId,
|
attachmentId,
|
||||||
@@ -292,12 +289,113 @@ export class ImportAttachmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wait for all uploads & DB inserts
|
// wait for all uploads & DB inserts
|
||||||
try {
|
uploadStats.total = attachmentTasks.length;
|
||||||
await Promise.all(attachmentTasks);
|
|
||||||
} catch (err) {
|
if (uploadStats.total > 0) {
|
||||||
this.logger.log('Import attachment upload error', err);
|
this.logger.debug(`Starting upload of ${uploadStats.total} attachments...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
attachmentTasks.map(task => limit(task))
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Import attachment upload error', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Upload completed: ${uploadStats.completed}/${uploadStats.total} successful, ${uploadStats.failed} failed`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uploadStats.failed > 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to upload ${uploadStats.failed} files:`,
|
||||||
|
uploadStats.failedFiles
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $.root().html() || '';
|
return $.root().html() || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async uploadWithRetry(opts: {
|
||||||
|
abs: string;
|
||||||
|
storageFilePath: string;
|
||||||
|
attachmentId: string;
|
||||||
|
fileNameWithExt: string;
|
||||||
|
ext: string;
|
||||||
|
pageId: string;
|
||||||
|
fileTask: FileTask;
|
||||||
|
uploadStats: {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
failedFiles: string[];
|
||||||
|
};
|
||||||
|
}): Promise<void> {
|
||||||
|
const {
|
||||||
|
abs,
|
||||||
|
storageFilePath,
|
||||||
|
attachmentId,
|
||||||
|
fileNameWithExt,
|
||||||
|
ext,
|
||||||
|
pageId,
|
||||||
|
fileTask,
|
||||||
|
uploadStats,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
let lastError: Error;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
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();
|
||||||
|
|
||||||
|
uploadStats.completed++;
|
||||||
|
|
||||||
|
if (uploadStats.completed % 10 === 0) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Upload progress: ${uploadStats.completed}/${uploadStats.total}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error;
|
||||||
|
this.logger.warn(
|
||||||
|
`Upload attempt ${attempt}/${this.MAX_RETRIES} failed for ${fileNameWithExt}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (attempt < this.MAX_RETRIES) {
|
||||||
|
await new Promise(resolve =>
|
||||||
|
setTimeout(resolve, this.RETRY_DELAY * attempt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadStats.failed++;
|
||||||
|
uploadStats.failedFiles.push(fileNameWithExt);
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to upload ${fileNameWithExt} after ${this.MAX_RETRIES} attempts:`,
|
||||||
|
lastError
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+17
@@ -534,6 +534,9 @@ importers:
|
|||||||
openid-client:
|
openid-client:
|
||||||
specifier: ^5.7.1
|
specifier: ^5.7.1
|
||||||
version: 5.7.1
|
version: 5.7.1
|
||||||
|
p-limit:
|
||||||
|
specifier: ^6.2.0
|
||||||
|
version: 6.2.0
|
||||||
passport-google-oauth20:
|
passport-google-oauth20:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
@@ -7637,6 +7640,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
p-limit@6.2.0:
|
||||||
|
resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
p-locate@4.1.0:
|
p-locate@4.1.0:
|
||||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -9567,6 +9574,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
yocto-queue@1.2.1:
|
||||||
|
resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==}
|
||||||
|
engines: {node: '>=12.20'}
|
||||||
|
|
||||||
yoctocolors-cjs@2.1.2:
|
yoctocolors-cjs@2.1.2:
|
||||||
resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==}
|
resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -18193,6 +18204,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yocto-queue: 0.1.0
|
yocto-queue: 0.1.0
|
||||||
|
|
||||||
|
p-limit@6.2.0:
|
||||||
|
dependencies:
|
||||||
|
yocto-queue: 1.2.1
|
||||||
|
|
||||||
p-locate@4.1.0:
|
p-locate@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 2.3.0
|
p-limit: 2.3.0
|
||||||
@@ -20183,6 +20198,8 @@ snapshots:
|
|||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
|
yocto-queue@1.2.1: {}
|
||||||
|
|
||||||
yoctocolors-cjs@2.1.2: {}
|
yoctocolors-cjs@2.1.2: {}
|
||||||
|
|
||||||
zeed-dom@0.15.1:
|
zeed-dom@0.15.1:
|
||||||
|
|||||||
Reference in New Issue
Block a user