mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-09 20:12:00 +10:00
feat: space export (#506)
* wip * Space export * option to export pages with children * include attachments in exports * unified export UI * cleanup * fix: change export icon * add export button to space settings * cleanups * export name
This commit is contained in:
149
apps/client/src/components/common/export-modal.tsx
Normal file
149
apps/client/src/components/common/export-modal.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Group,
|
||||
Text,
|
||||
Select,
|
||||
Switch,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||
import { useState } from "react";
|
||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { exportSpace } from "@/features/space/services/space-service";
|
||||
|
||||
interface ExportModalProps {
|
||||
id: string;
|
||||
type: "space" | "page";
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ExportModal({
|
||||
id,
|
||||
type,
|
||||
open,
|
||||
onClose,
|
||||
}: ExportModalProps) {
|
||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(true);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
if (type === "page") {
|
||||
await exportPage({ pageId: id, format, includeChildren });
|
||||
}
|
||||
if (type === "space") {
|
||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||
}
|
||||
setIncludeChildren(false);
|
||||
setIncludeAttachments(true);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: "Export failed:" + err.response?.data.message,
|
||||
color: "red",
|
||||
});
|
||||
console.error("export error", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (format: ExportFormat) => {
|
||||
setFormat(format);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
opened={open}
|
||||
onClose={onClose}
|
||||
size={500}
|
||||
padding="xl"
|
||||
yOffset="10vh"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>Export {type}</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">Format</Text>
|
||||
</div>
|
||||
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||
</Group>
|
||||
|
||||
{type === "page" && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">Include subpages</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={(event) =>
|
||||
setIncludeChildren(event.currentTarget.checked)
|
||||
}
|
||||
checked={includeChildren}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "space" && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">Include attachments</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={(event) =>
|
||||
setIncludeAttachments(event.currentTarget.checked)
|
||||
}
|
||||
checked={includeAttachments}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="center" mt="md">
|
||||
<Button onClick={onClose} variant="default">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport}>Export</Button>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExportFormatSelection {
|
||||
format: ExportFormat;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||
return (
|
||||
<Select
|
||||
data={[
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "html", label: "HTML" },
|
||||
]}
|
||||
defaultValue={format}
|
||||
onChange={onChange}
|
||||
styles={{ wrapper: { maxWidth: 120 } }}
|
||||
comboboxProps={{ width: "120" }}
|
||||
allowDeselect={false}
|
||||
withCheckIcon={false}
|
||||
aria-label="Select export format"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { ActionIcon, Group, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArrowsHorizontal,
|
||||
IconDots,
|
||||
IconDownload,
|
||||
IconFileExport,
|
||||
IconHistory,
|
||||
IconLink,
|
||||
IconMessage,
|
||||
@ -24,6 +24,7 @@ import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
||||
import PageExportModal from "@/features/page/components/page-export-modal.tsx";
|
||||
import ExportModal from "@/components/common/export-modal";
|
||||
|
||||
interface PageHeaderMenuProps {
|
||||
readOnly?: boolean;
|
||||
@ -126,7 +127,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconDownload size={16} />}
|
||||
leftSection={<IconFileExport size={16} />}
|
||||
onClick={openExportModal}
|
||||
>
|
||||
Export
|
||||
@ -154,8 +155,9 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<PageExportModal
|
||||
pageId={page.id}
|
||||
<ExportModal
|
||||
type="page"
|
||||
id={page.id}
|
||||
open={exportOpened}
|
||||
onClose={closeExportModal}
|
||||
/>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Modal, Button, Group, Text, Select } from "@mantine/core";
|
||||
import { Modal, Button, Group, Text, Select, Switch } from "@mantine/core";
|
||||
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||
import { useState } from "react";
|
||||
import * as React from "react";
|
||||
@ -57,8 +57,18 @@ export default function PageExportModal({
|
||||
<Text size="md">Format</Text>
|
||||
</div>
|
||||
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" wrap="nowrap" pt="md">
|
||||
<div>
|
||||
<Text size="md">Include subpages</Text>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
|
||||
</Group>
|
||||
|
||||
|
||||
<Group justify="center" mt="md">
|
||||
<Button onClick={onClose} variant="default">
|
||||
Cancel
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconDotsVertical,
|
||||
IconFileDescription,
|
||||
IconFileDescription, IconFileExport,
|
||||
IconLink,
|
||||
IconPlus,
|
||||
IconPointFilled,
|
||||
@ -39,7 +39,12 @@ import {
|
||||
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
||||
import { useClipboard, useElementSize, useMergedRef } from "@mantine/hooks";
|
||||
import {
|
||||
useClipboard,
|
||||
useDisclosure,
|
||||
useElementSize,
|
||||
useMergedRef,
|
||||
} from "@mantine/hooks";
|
||||
import { dfs } from "react-arborist/dist/module/utils";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@ -47,6 +52,7 @@ import { notifications } from "@mantine/notifications";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||
import ExportModal from "@/components/common/export-modal";
|
||||
|
||||
interface SpaceTreeProps {
|
||||
spaceId: string;
|
||||
@ -402,6 +408,8 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const { spaceSlug } = useParams();
|
||||
const { openDeleteModal } = useDeletePageModal();
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
@ -411,56 +419,76 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<IconDotsVertical
|
||||
style={{ width: rem(20), height: rem(20) }}
|
||||
stroke={2}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<>
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<IconDotsVertical
|
||||
style={{ width: rem(20), height: rem(20) }}
|
||||
stroke={2}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyLink();
|
||||
}}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyLink();
|
||||
}}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
|
||||
{!(treeApi.props.disableEdit as boolean) && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconFileExport size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openExportModal();
|
||||
}}
|
||||
>
|
||||
Export page
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
c="red"
|
||||
leftSection={
|
||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{!(treeApi.props.disableEdit as boolean) && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
c="red"
|
||||
leftSection={
|
||||
<IconTrash size={16} />
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<ExportModal
|
||||
type="page"
|
||||
id={node.id}
|
||||
open={exportOpened}
|
||||
onClose={closeExportModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -48,6 +48,7 @@ export interface IPageInput {
|
||||
export interface IExportPageParams {
|
||||
pageId: string;
|
||||
format: ExportFormat;
|
||||
includeChildren?: boolean;
|
||||
}
|
||||
|
||||
export enum ExportFormat {
|
||||
|
||||
@ -5,36 +5,38 @@ import {
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { spotlight } from '@mantine/spotlight';
|
||||
} from "@mantine/core";
|
||||
import { spotlight } from "@mantine/spotlight";
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconDots,
|
||||
IconFileExport,
|
||||
IconHome,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
} from '@tabler/icons-react';
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import classes from './space-sidebar.module.css';
|
||||
import React, { useMemo } 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';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import clsx from 'clsx';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import SpaceSettingsModal from '@/features/space/components/settings-modal.tsx';
|
||||
import { useGetSpaceBySlugQuery } from '@/features/space/queries/space-query.ts';
|
||||
import { getSpaceUrl } from '@/lib/config.ts';
|
||||
import SpaceTree from '@/features/page/tree/components/space-tree.tsx';
|
||||
import { useSpaceAbility } from '@/features/space/permissions/use-space-ability.ts';
|
||||
import classes from "./space-sidebar.module.css";
|
||||
import React, { useMemo } 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";
|
||||
import { Link, useLocation, useParams } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '@/features/space/permissions/permissions.type.ts';
|
||||
import PageImportModal from '@/features/page/components/page-import-modal.tsx';
|
||||
import { SwitchSpace } from './switch-space';
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
import PageImportModal from "@/features/page/components/page-import-modal.tsx";
|
||||
import { SwitchSpace } from "./switch-space";
|
||||
import ExportModal from "@/components/common/export-modal";
|
||||
|
||||
export function SpaceSidebar() {
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
@ -52,7 +54,7 @@ export function SpaceSidebar() {
|
||||
}
|
||||
|
||||
function handleCreatePage() {
|
||||
tree?.create({ parentId: null, type: 'internal', index: 0 });
|
||||
tree?.create({ parentId: null, type: "internal", index: 0 });
|
||||
}
|
||||
|
||||
return (
|
||||
@ -61,7 +63,7 @@ export function SpaceSidebar() {
|
||||
<div
|
||||
className={classes.section}
|
||||
style={{
|
||||
border: 'none',
|
||||
border: "none",
|
||||
marginTop: 2,
|
||||
marginBottom: 3,
|
||||
}}
|
||||
@ -78,7 +80,7 @@ export function SpaceSidebar() {
|
||||
classes.menu,
|
||||
location.pathname.toLowerCase() === getSpaceUrl(spaceSlug)
|
||||
? classes.activeButton
|
||||
: ''
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
<div className={classes.menuItemInner}>
|
||||
@ -191,6 +193,8 @@ interface SpaceMenuProps {
|
||||
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
||||
useDisclosure(false);
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -215,6 +219,13 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||
Import pages
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
onClick={openExportModal}
|
||||
leftSection={<IconFileExport size={16} />}
|
||||
>
|
||||
Export space
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
@ -231,6 +242,13 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||
open={importOpened}
|
||||
onClose={closeImportModal}
|
||||
/>
|
||||
|
||||
<ExportModal
|
||||
type="space"
|
||||
id={spaceId}
|
||||
open={exportOpened}
|
||||
onClose={closeExportModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useSpaceQuery } from '@/features/space/queries/space-query.ts';
|
||||
import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx';
|
||||
import { Divider, Group, Text } from '@mantine/core';
|
||||
import { Button, Divider, Group, Text } from '@mantine/core';
|
||||
import DeleteSpaceModal from './delete-space-modal';
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import ExportModal from "@/components/common/export-modal.tsx";
|
||||
|
||||
interface SpaceDetailsProps {
|
||||
spaceId: string;
|
||||
@ -10,6 +12,8 @@ interface SpaceDetailsProps {
|
||||
}
|
||||
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
const { data: space, isLoading } = useSpaceQuery(spaceId);
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -22,6 +26,22 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
|
||||
{!readOnly && (
|
||||
<>
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">Export space</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Export all pages and attachments in this space
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button onClick={openExportModal}>
|
||||
Export
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
@ -34,6 +54,13 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
|
||||
<DeleteSpaceModal space={space} />
|
||||
</Group>
|
||||
|
||||
<ExportModal
|
||||
type="space"
|
||||
id={space.id}
|
||||
open={exportOpened}
|
||||
onClose={closeExportModal}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,56 +1,72 @@
|
||||
import api from '@/lib/api-client';
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IAddSpaceMember,
|
||||
IChangeSpaceMemberRole,
|
||||
IExportSpaceParams,
|
||||
IRemoveSpaceMember,
|
||||
ISpace,
|
||||
} from "@/features/space/types/space.types";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
export async function getSpaces(params?: QueryParams): Promise<IPagination<ISpace>> {
|
||||
export async function getSpaces(
|
||||
params?: QueryParams
|
||||
): Promise<IPagination<ISpace>> {
|
||||
const req = await api.post("/spaces", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getSpaceById(spaceId: string): Promise<ISpace> {
|
||||
const req = await api.post<ISpace>('/spaces/info', { spaceId });
|
||||
const req = await api.post<ISpace>("/spaces/info", { spaceId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function createSpace(data: Partial<ISpace>): Promise<ISpace> {
|
||||
const req = await api.post<ISpace>('/spaces/create', data);
|
||||
const req = await api.post<ISpace>("/spaces/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updateSpace(data: Partial<ISpace>): Promise<ISpace> {
|
||||
const req = await api.post<ISpace>('/spaces/update', data);
|
||||
const req = await api.post<ISpace>("/spaces/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deleteSpace(spaceId: string): Promise<void> {
|
||||
await api.post<void>('/spaces/delete', { spaceId });
|
||||
await api.post<void>("/spaces/delete", { spaceId });
|
||||
}
|
||||
|
||||
export async function getSpaceMembers(
|
||||
spaceId: string
|
||||
): Promise<IPagination<IUser>> {
|
||||
const req = await api.post<any>('/spaces/members', { spaceId });
|
||||
const req = await api.post<any>("/spaces/members", { spaceId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function addSpaceMember(data: IAddSpaceMember): Promise<void> {
|
||||
await api.post('/spaces/members/add', data);
|
||||
await api.post("/spaces/members/add", data);
|
||||
}
|
||||
|
||||
export async function removeSpaceMember(
|
||||
data: IRemoveSpaceMember
|
||||
): Promise<void> {
|
||||
await api.post('/spaces/members/remove', data);
|
||||
await api.post("/spaces/members/remove", data);
|
||||
}
|
||||
|
||||
export async function changeMemberRole(
|
||||
data: IChangeSpaceMemberRole
|
||||
): Promise<void> {
|
||||
await api.post('/spaces/members/change-role', data);
|
||||
await api.post("/spaces/members/change-role", data);
|
||||
}
|
||||
|
||||
export async function exportSpace(data: IExportSpaceParams): Promise<void> {
|
||||
const req = await api.post("/spaces/export", data, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
const fileName = req?.headers["content-disposition"]
|
||||
.split("filename=")[1]
|
||||
.replace(/"/g, "");
|
||||
|
||||
saveAs(req.data, decodeURIComponent(fileName));
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||
|
||||
export interface ISpace {
|
||||
id: string;
|
||||
@ -68,3 +69,9 @@ export interface SpaceGroupInfo {
|
||||
}
|
||||
|
||||
export type ISpaceMember = { role: string } & (SpaceUserInfo | SpaceGroupInfo);
|
||||
|
||||
export interface IExportSpaceParams {
|
||||
spaceId: string;
|
||||
format: ExportFormat;
|
||||
includeAttachments?: boolean;
|
||||
}
|
||||
@ -26,14 +26,18 @@ api.interceptors.request.use(
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
// we need the response headers
|
||||
if (response.request.responseURL.includes("/api/pages/export")) {
|
||||
return response;
|
||||
// we need the response headers for these endpoints
|
||||
const exemptEndpoints = ["/api/pages/export", "/api/spaces/export"];
|
||||
if (response.request.responseURL) {
|
||||
const path = new URL(response.request.responseURL)?.pathname;
|
||||
if (path && exemptEndpoints.includes(path)) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
return response.data;
|
||||
@ -72,7 +76,7 @@ api.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function redirectToLogin() {
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import {StarterKit} from '@tiptap/starter-kit';
|
||||
import {TextAlign} from '@tiptap/extension-text-align';
|
||||
import {TaskList} from '@tiptap/extension-task-list';
|
||||
import {TaskItem} from '@tiptap/extension-task-item';
|
||||
import {Underline} from '@tiptap/extension-underline';
|
||||
import {Superscript} from '@tiptap/extension-superscript';
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
import { TextAlign } from '@tiptap/extension-text-align';
|
||||
import { TaskList } from '@tiptap/extension-task-list';
|
||||
import { TaskItem } from '@tiptap/extension-task-item';
|
||||
import { Underline } from '@tiptap/extension-underline';
|
||||
import { Superscript } from '@tiptap/extension-superscript';
|
||||
import SubScript from '@tiptap/extension-subscript';
|
||||
import {Highlight} from '@tiptap/extension-highlight';
|
||||
import {Typography} from '@tiptap/extension-typography';
|
||||
import {TextStyle} from '@tiptap/extension-text-style';
|
||||
import {Color} from '@tiptap/extension-color';
|
||||
import {Youtube} from '@tiptap/extension-youtube';
|
||||
import { Highlight } from '@tiptap/extension-highlight';
|
||||
import { Typography } from '@tiptap/extension-typography';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import { Color } from '@tiptap/extension-color';
|
||||
import { Youtube } from '@tiptap/extension-youtube';
|
||||
import Table from '@tiptap/extension-table';
|
||||
import TableHeader from '@tiptap/extension-table-header';
|
||||
import {
|
||||
@ -30,14 +30,15 @@ import {
|
||||
Attachment,
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed
|
||||
Embed,
|
||||
} from '@docmost/editor-ext';
|
||||
import {generateText, JSONContent} from '@tiptap/core';
|
||||
import {generateHTML} from '../common/helpers/prosemirror/html';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML } from '../common/helpers/prosemirror/html';
|
||||
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
||||
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
||||
import {generateJSON} from '@tiptap/html';
|
||||
import { generateJSON } from '@tiptap/html';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
|
||||
export const tiptapExtensions = [
|
||||
StarterKit.configure({
|
||||
@ -73,7 +74,7 @@ export const tiptapExtensions = [
|
||||
CustomCodeBlock,
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed
|
||||
Embed,
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: any) {
|
||||
@ -88,6 +89,10 @@ export function jsonToText(tiptapJson: JSONContent) {
|
||||
return generateText(tiptapJson, tiptapExtensions);
|
||||
}
|
||||
|
||||
export function jsonToNode(tiptapJson: JSONContent) {
|
||||
return Node.fromJSON(getSchema(tiptapExtensions), tiptapJson);
|
||||
}
|
||||
|
||||
export function getPageId(documentName: string) {
|
||||
return documentName.split('.')[1];
|
||||
}
|
||||
|
||||
@ -160,4 +160,30 @@ export class PageRepo {
|
||||
.whereRef('spaces.id', '=', 'pages.spaceId'),
|
||||
).as('space');
|
||||
}
|
||||
|
||||
async getPageAndDescendants(parentPageId: string) {
|
||||
return this.db
|
||||
.withRecursive('page_hierarchy', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'slugId', 'title', 'icon', 'content', 'parentPageId'])
|
||||
.where('id', '=', parentPageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.content',
|
||||
'p.parentPageId',
|
||||
])
|
||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.selectAll()
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,5 +22,19 @@ export class ExportPageDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
includeFiles?: boolean;
|
||||
includeChildren?: boolean;
|
||||
}
|
||||
|
||||
export class ExportSpaceDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
spaceId: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['html', 'markdown'])
|
||||
format: ExportFormat;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
includeAttachments?: boolean;
|
||||
}
|
||||
@ -10,7 +10,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ExportService } from './export.service';
|
||||
import { ExportPageDto } from './dto/export-dto';
|
||||
import { ExportPageDto, ExportSpaceDto } from './dto/export-dto';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
||||
@ -54,10 +54,28 @@ export class ImportController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const rawContent = await this.exportService.exportPage(dto.format, page);
|
||||
|
||||
const fileExt = getExportExtension(dto.format);
|
||||
const fileName = sanitize(page.title || 'Untitled') + fileExt;
|
||||
const fileName = sanitize(page.title || 'untitled') + fileExt;
|
||||
|
||||
if (dto.includeChildren) {
|
||||
const zipFileBuffer = await this.exportService.exportPageWithChildren(
|
||||
dto.pageId,
|
||||
dto.format,
|
||||
);
|
||||
|
||||
const newName = fileName + '.zip';
|
||||
|
||||
res.headers({
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition':
|
||||
'attachment; filename="' + encodeURIComponent(newName) + '"',
|
||||
});
|
||||
|
||||
res.send(zipFileBuffer);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawContent = await this.exportService.exportPage(dto.format, page);
|
||||
|
||||
res.headers({
|
||||
'Content-Type': getMimeType(fileExt),
|
||||
@ -67,4 +85,34 @@ export class ImportController {
|
||||
|
||||
res.send(rawContent);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('spaces/export')
|
||||
async exportSpace(
|
||||
@Body() dto: ExportSpaceDto,
|
||||
@AuthUser() user: User,
|
||||
@Res() res: FastifyReply,
|
||||
) {
|
||||
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const exportFile = await this.exportService.exportSpace(
|
||||
dto.spaceId,
|
||||
dto.format,
|
||||
dto.includeAttachments,
|
||||
);
|
||||
|
||||
res.headers({
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition':
|
||||
'attachment; filename="' +
|
||||
encodeURIComponent(sanitize(exportFile.fileName)) +
|
||||
'"',
|
||||
});
|
||||
|
||||
res.send(exportFile.fileBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ExportService } from './export.service';
|
||||
import { ImportController } from './export.controller';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [StorageModule],
|
||||
providers: [ExportService],
|
||||
controllers: [ImportController],
|
||||
})
|
||||
|
||||
@ -1,19 +1,48 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { jsonToHtml } from '../../collaboration/collaboration.util';
|
||||
import { turndown } from './turndown-utils';
|
||||
import { ExportFormat } from './dto/export-dto';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import * as JSZip from 'jszip';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import {
|
||||
buildTree,
|
||||
computeLocalPath,
|
||||
getAttachmentIds,
|
||||
getExportExtension,
|
||||
getPageTitle,
|
||||
getProsemirrorContent,
|
||||
PageExportTree,
|
||||
replaceInternalLinks,
|
||||
updateAttachmentUrls,
|
||||
} from './utils';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
private readonly logger = new Logger(ExportService.name);
|
||||
|
||||
constructor(
|
||||
private readonly pageRepo: PageRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly storageService: StorageService,
|
||||
) {}
|
||||
|
||||
async exportPage(format: string, page: Page) {
|
||||
const titleNode = {
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: page.title }],
|
||||
content: [{ type: 'text', text: getPageTitle(page.title) }],
|
||||
};
|
||||
|
||||
let prosemirrorJson: any = page.content || { type: 'doc', content: [] };
|
||||
let prosemirrorJson: any = getProsemirrorContent(page.content);
|
||||
|
||||
if (page.title) {
|
||||
prosemirrorJson.content.unshift(titleNode);
|
||||
@ -22,7 +51,13 @@ export class ExportService {
|
||||
const pageHtml = jsonToHtml(prosemirrorJson);
|
||||
|
||||
if (format === ExportFormat.HTML) {
|
||||
return `<!DOCTYPE html><html><head><title>${page.title}</title></head><body>${pageHtml}</body></html>`;
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>${getPageTitle(page.title)}</title>
|
||||
</head>
|
||||
<body>${pageHtml}</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
if (format === ExportFormat.Markdown) {
|
||||
@ -31,4 +66,156 @@ export class ExportService {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async exportPageWithChildren(pageId: string, format: string) {
|
||||
const pages = await this.pageRepo.getPageAndDescendants(pageId);
|
||||
|
||||
if (!pages || pages.length === 0) {
|
||||
throw new BadRequestException('No pages to export');
|
||||
}
|
||||
|
||||
const parentPageIndex = pages.findIndex((obj) => obj.id === pageId);
|
||||
// set to null to make export of pages with parentId work
|
||||
pages[parentPageIndex].parentPageId = null;
|
||||
|
||||
const tree = buildTree(pages as Page[]);
|
||||
|
||||
const zip = new JSZip();
|
||||
await this.zipPages(tree, format, zip);
|
||||
|
||||
const zipFile = zip.generateNodeStream({
|
||||
type: 'nodebuffer',
|
||||
streamFiles: true,
|
||||
compression: 'DEFLATE',
|
||||
});
|
||||
|
||||
return zipFile;
|
||||
}
|
||||
|
||||
async exportSpace(
|
||||
spaceId: string,
|
||||
format: string,
|
||||
includeAttachments: boolean,
|
||||
) {
|
||||
const space = await this.db
|
||||
.selectFrom('spaces')
|
||||
.selectAll()
|
||||
.where('id', '=', spaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!space) {
|
||||
throw new NotFoundException('Space not found');
|
||||
}
|
||||
|
||||
const pages = await this.db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.content',
|
||||
'pages.parentPageId',
|
||||
])
|
||||
.where('spaceId', '=', spaceId)
|
||||
.execute();
|
||||
|
||||
const tree = buildTree(pages as Page[]);
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
await this.zipPages(tree, format, zip, includeAttachments);
|
||||
|
||||
const zipFile = zip.generateNodeStream({
|
||||
type: 'nodebuffer',
|
||||
streamFiles: true,
|
||||
compression: 'DEFLATE',
|
||||
});
|
||||
|
||||
const fileName = `${space.name}-space-export${getExportExtension(format)}.zip`;
|
||||
return {
|
||||
fileBuffer: zipFile,
|
||||
fileName,
|
||||
};
|
||||
}
|
||||
|
||||
async zipPages(
|
||||
tree: PageExportTree,
|
||||
format: string,
|
||||
zip: JSZip,
|
||||
includeAttachments = true,
|
||||
): Promise<void> {
|
||||
const slugIdToPath: Record<string, string> = {};
|
||||
|
||||
computeLocalPath(tree, format, null, '', slugIdToPath);
|
||||
|
||||
const stack: { folder: JSZip; parentPageId: string }[] = [
|
||||
{ folder: zip, parentPageId: null },
|
||||
];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const { folder, parentPageId } = stack.pop();
|
||||
const children = tree[parentPageId] || [];
|
||||
|
||||
for (const page of children) {
|
||||
const childPages = tree[page.id] || [];
|
||||
|
||||
const prosemirrorJson = getProsemirrorContent(page.content);
|
||||
|
||||
const currentPagePath = slugIdToPath[page.slugId];
|
||||
|
||||
let updatedJsonContent = replaceInternalLinks(
|
||||
prosemirrorJson,
|
||||
slugIdToPath,
|
||||
currentPagePath,
|
||||
);
|
||||
|
||||
if (includeAttachments) {
|
||||
await this.zipAttachments(updatedJsonContent, page.spaceId, folder);
|
||||
updatedJsonContent = updateAttachmentUrls(updatedJsonContent);
|
||||
}
|
||||
|
||||
const pageTitle = getPageTitle(page.title);
|
||||
const pageExportContent = await this.exportPage(format, {
|
||||
...page,
|
||||
content: updatedJsonContent,
|
||||
});
|
||||
|
||||
folder.file(
|
||||
`${pageTitle}${getExportExtension(format)}`,
|
||||
pageExportContent,
|
||||
);
|
||||
if (childPages.length > 0) {
|
||||
const pageFolder = folder.folder(pageTitle);
|
||||
stack.push({ folder: pageFolder, parentPageId: page.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async zipAttachments(prosemirrorJson: any, spaceId: string, zip: JSZip) {
|
||||
const attachmentIds = getAttachmentIds(prosemirrorJson);
|
||||
|
||||
if (attachmentIds.length > 0) {
|
||||
const attachments = await this.db
|
||||
.selectFrom('attachments')
|
||||
.selectAll()
|
||||
.where('id', 'in', attachmentIds)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.execute();
|
||||
|
||||
await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
try {
|
||||
const fileBuffer = await this.storageService.read(
|
||||
attachment.filePath,
|
||||
);
|
||||
const filePath = `/files/${attachment.id}/${attachment.fileName}`;
|
||||
zip.file(filePath, fileBuffer);
|
||||
} catch (err) {
|
||||
this.logger.debug(`Attachment export error ${attachment.id}`, err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { jsonToNode } from 'src/collaboration/collaboration.util';
|
||||
import { ExportFormat } from './dto/export-dto';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
import { validate as isValidUUID } from 'uuid';
|
||||
import * as path from 'path';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
|
||||
export type PageExportTree = Record<string, Page[]>;
|
||||
|
||||
export function getExportExtension(format: string) {
|
||||
if (format === ExportFormat.HTML) {
|
||||
@ -10,3 +17,171 @@ export function getExportExtension(format: string) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
export function getPageTitle(title: string) {
|
||||
return title ? title : 'untitled';
|
||||
}
|
||||
|
||||
export function getProsemirrorContent(content: any) {
|
||||
return (
|
||||
content ?? {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', attrs: { textAlign: 'left' } }],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function getAttachmentIds(prosemirrorJson: any) {
|
||||
const doc = jsonToNode(prosemirrorJson);
|
||||
const attachmentIds = [];
|
||||
|
||||
doc?.descendants((node: Node) => {
|
||||
if (isAttachmentNode(node.type.name)) {
|
||||
if (node.attrs.attachmentId && isValidUUID(node.attrs.attachmentId)) {
|
||||
if (!attachmentIds.includes(node.attrs.attachmentId)) {
|
||||
attachmentIds.push(node.attrs.attachmentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return attachmentIds;
|
||||
}
|
||||
|
||||
export function isAttachmentNode(nodeType: string) {
|
||||
const attachmentNodeTypes = [
|
||||
'attachment',
|
||||
'image',
|
||||
'video',
|
||||
'excalidraw',
|
||||
'drawio',
|
||||
];
|
||||
return attachmentNodeTypes.includes(nodeType);
|
||||
}
|
||||
|
||||
export function updateAttachmentUrls(prosemirrorJson: any) {
|
||||
const doc = jsonToNode(prosemirrorJson);
|
||||
|
||||
doc?.descendants((node: Node) => {
|
||||
if (isAttachmentNode(node.type.name)) {
|
||||
if (node.attrs.src && node.attrs.src.startsWith('/files')) {
|
||||
//@ts-expect-error
|
||||
node.attrs.src = node.attrs.src.replace('/files', 'files');
|
||||
} else if (node.attrs.url && node.attrs.url.startsWith('/files')) {
|
||||
//@ts-expect-error
|
||||
node.attrs.url = node.attrs.url.replace('/files', 'files');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return doc.toJSON();
|
||||
}
|
||||
|
||||
export function replaceInternalLinks(
|
||||
prosemirrorJson: any,
|
||||
slugIdToPath: Record<string, string>,
|
||||
currentPagePath: string,
|
||||
) {
|
||||
const doc = jsonToNode(prosemirrorJson);
|
||||
const internalLinkRegex =
|
||||
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
|
||||
|
||||
doc.descendants((node: Node) => {
|
||||
for (const mark of node.marks) {
|
||||
if (mark.type.name === 'link' && mark.attrs.href) {
|
||||
const match = mark.attrs.href.match(internalLinkRegex);
|
||||
if (match) {
|
||||
const markLink = mark.attrs.href;
|
||||
|
||||
const slugId = extractPageSlugId(match[5]);
|
||||
const localPath = slugIdToPath[slugId];
|
||||
|
||||
if (!localPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = computeRelativePath(currentPagePath, localPath);
|
||||
|
||||
//@ts-expect-error
|
||||
mark.attrs.href = relativePath;
|
||||
//@ts-expect-error
|
||||
mark.attrs.target = '_self';
|
||||
if (node.isText) {
|
||||
// if link and text are same, use page title
|
||||
if (markLink === node.text) {
|
||||
//@ts-expect-error
|
||||
node.text = getInternalLinkPageName(relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return doc.toJSON();
|
||||
}
|
||||
|
||||
export function getInternalLinkPageName(path: string): string {
|
||||
return decodeURIComponent(
|
||||
path?.split('/').pop().split('.').slice(0, -1).join('.'),
|
||||
);
|
||||
}
|
||||
|
||||
export function extractPageSlugId(input: string): string {
|
||||
if (!input) {
|
||||
return undefined;
|
||||
}
|
||||
const parts = input.split('-');
|
||||
return parts.length > 1 ? parts[parts.length - 1] : input;
|
||||
}
|
||||
|
||||
export function buildTree(pages: Page[]): PageExportTree {
|
||||
const tree: PageExportTree = {};
|
||||
const titleCount: Record<string, Record<string, number>> = {};
|
||||
|
||||
for (const page of pages) {
|
||||
const parentPageId = page.parentPageId;
|
||||
|
||||
if (!titleCount[parentPageId]) {
|
||||
titleCount[parentPageId] = {};
|
||||
}
|
||||
|
||||
let title = getPageTitle(page.title);
|
||||
|
||||
if (titleCount[parentPageId][title]) {
|
||||
title = `${title} (${titleCount[parentPageId][title]})`;
|
||||
titleCount[parentPageId][getPageTitle(page.title)] += 1;
|
||||
} else {
|
||||
titleCount[parentPageId][title] = 1;
|
||||
}
|
||||
|
||||
page.title = title;
|
||||
if (!tree[parentPageId]) {
|
||||
tree[parentPageId] = [];
|
||||
}
|
||||
tree[parentPageId].push(page);
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
export function computeLocalPath(
|
||||
tree: PageExportTree,
|
||||
format: string,
|
||||
parentPageId: string | null,
|
||||
currentPath: string,
|
||||
slugIdToPath: Record<string, string>,
|
||||
) {
|
||||
const children = tree[parentPageId] || [];
|
||||
|
||||
for (const page of children) {
|
||||
const title = encodeURIComponent(getPageTitle(page.title));
|
||||
const localPath = `${currentPath}${title}`;
|
||||
slugIdToPath[page.slugId] = `${localPath}${getExportExtension(format)}`;
|
||||
|
||||
computeLocalPath(tree, format, page.id, `${localPath}/`, slugIdToPath);
|
||||
}
|
||||
}
|
||||
|
||||
function computeRelativePath(from: string, to: string) {
|
||||
return path.relative(path.dirname(from), to);
|
||||
}
|
||||
|
||||
@ -63,6 +63,7 @@
|
||||
"cross-env": "^7.0.3",
|
||||
"fractional-indexing-jittered": "^0.9.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"jszip": "^3.10.1",
|
||||
"uuid": "^10.0.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"yjs": "^13.6.18"
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import TiptapLink from "@tiptap/extension-link";
|
||||
import { Plugin } from "@tiptap/pm/state";
|
||||
import { EditorView } from "@tiptap/pm/view";
|
||||
@ -5,6 +6,24 @@ import { EditorView } from "@tiptap/pm/view";
|
||||
export const LinkExtension = TiptapLink.extend({
|
||||
inclusive: false,
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"a",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
class: "link",
|
||||
}),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
|
||||
|
||||
70
pnpm-lock.yaml
generated
70
pnpm-lock.yaml
generated
@ -154,6 +154,9 @@ importers:
|
||||
ioredis:
|
||||
specifier: ^5.4.1
|
||||
version: 5.4.1
|
||||
jszip:
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.1
|
||||
uuid:
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
@ -5434,6 +5437,9 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
hasBin: true
|
||||
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
import-fresh@3.3.0:
|
||||
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
|
||||
engines: {node: '>=6'}
|
||||
@ -5552,6 +5558,9 @@ packages:
|
||||
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
@ -5830,6 +5839,9 @@ packages:
|
||||
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
jwa@1.4.1:
|
||||
resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==}
|
||||
|
||||
@ -5940,6 +5952,9 @@ packages:
|
||||
libphonenumber-js@1.10.58:
|
||||
resolution: {integrity: sha512-53A0IpJFL9LdHbpeatwizf8KSwPICrqn9H0g3Y7WQ+Jgeu9cQ4Ew3WrRtrLBu/CX2lXd5+rgT01/tGlkbkzOjw==}
|
||||
|
||||
lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
light-my-request@5.13.0:
|
||||
resolution: {integrity: sha512-9IjUN9ZyCS9pTG+KqTDEQo68Sui2lHsYBrfMyVUTTZ3XhH8PMZq7xO94Kr+eP9dhi/kcKsx4N41p2IXEBil1pQ==}
|
||||
|
||||
@ -6429,6 +6444,9 @@ packages:
|
||||
package-json-from-dist@1.0.0:
|
||||
resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@ -6696,6 +6714,9 @@ packages:
|
||||
resolution: {integrity: sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
process-warning@3.0.0:
|
||||
resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==}
|
||||
|
||||
@ -6958,6 +6979,9 @@ packages:
|
||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
@ -7118,6 +7142,9 @@ packages:
|
||||
rxjs@7.8.1:
|
||||
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
@ -7190,6 +7217,9 @@ packages:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
@ -7314,6 +7344,9 @@ packages:
|
||||
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
@ -13818,6 +13851,8 @@ snapshots:
|
||||
image-size@0.5.5:
|
||||
optional: true
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
import-fresh@3.3.0:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
@ -13958,6 +13993,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-docker: 2.2.1
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isomorphic.js@0.2.5: {}
|
||||
@ -14446,6 +14483,13 @@ snapshots:
|
||||
ms: 2.1.3
|
||||
semver: 7.6.0
|
||||
|
||||
jszip@3.10.1:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
pako: 1.0.11
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
jwa@1.4.1:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
@ -14539,6 +14583,10 @@ snapshots:
|
||||
|
||||
libphonenumber-js@1.10.58: {}
|
||||
|
||||
lie@3.3.0:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
light-my-request@5.13.0:
|
||||
dependencies:
|
||||
cookie: 0.6.0
|
||||
@ -15035,6 +15083,8 @@ snapshots:
|
||||
|
||||
package-json-from-dist@1.0.0: {}
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@ -15288,6 +15338,8 @@ snapshots:
|
||||
|
||||
proc-log@3.0.0: {}
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
process-warning@3.0.0: {}
|
||||
|
||||
process@0.11.10: {}
|
||||
@ -15612,6 +15664,16 @@ snapshots:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
core-util-is: 1.0.3
|
||||
inherits: 2.0.4
|
||||
isarray: 1.0.0
|
||||
process-nextick-args: 2.0.1
|
||||
safe-buffer: 5.1.2
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readable-stream@3.6.2:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
@ -15780,6 +15842,8 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.6.2
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safe-regex2@2.0.0:
|
||||
@ -15847,6 +15911,8 @@ snapshots:
|
||||
gopd: 1.0.1
|
||||
has-property-descriptors: 1.0.2
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
shallowequal@1.1.0: {}
|
||||
@ -15983,6 +16049,10 @@ snapshots:
|
||||
emoji-regex: 9.2.2
|
||||
strip-ansi: 7.1.0
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
Reference in New Issue
Block a user