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:
Philip Okugbe
2024-11-30 19:47:22 +00:00
committed by GitHub
parent 9fa432dba9
commit fe83557767
20 changed files with 926 additions and 117 deletions

View 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"
/>
);
}

View File

@ -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}
/>

View File

@ -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

View File

@ -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}
/>
</>
);
}

View File

@ -48,6 +48,7 @@ export interface IPageInput {
export interface IExportPageParams {
pageId: string;
format: ExportFormat;
includeChildren?: boolean;
}
export enum ExportFormat {

View File

@ -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}
/>
</>
);
}

View File

@ -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>

View File

@ -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));
}

View File

@ -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;
}

View File

@ -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() {

View File

@ -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];
}

View File

@ -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();
}
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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],
})

View File

@ -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);
}
}),
);
}
}
}

View File

@ -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);
}