feat: add attachments support for single page exports (#1440)

* feat: add attachments support for single page exports
- Add includeAttachments option to page export modal and API
- Fix internal page url in single page exports in cloud

* remove redundant line

* preserve export state
This commit is contained in:
Philip Okugbe
2025-08-04 08:01:11 +01:00
committed by GitHub
parent aa6eec754e
commit dddfd48934
6 changed files with 58 additions and 142 deletions

View File

@ -29,19 +29,22 @@ export default function ExportModal({
}: ExportModalProps) { }: ExportModalProps) {
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown); const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const [includeChildren, setIncludeChildren] = useState<boolean>(false); const [includeChildren, setIncludeChildren] = useState<boolean>(false);
const [includeAttachments, setIncludeAttachments] = useState<boolean>(true); const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
const { t } = useTranslation(); const { t } = useTranslation();
const handleExport = async () => { const handleExport = async () => {
try { try {
if (type === "page") { if (type === "page") {
await exportPage({ pageId: id, format, includeChildren }); await exportPage({
pageId: id,
format,
includeChildren,
includeAttachments,
});
} }
if (type === "space") { if (type === "space") {
await exportSpace({ spaceId: id, format, includeAttachments }); await exportSpace({ spaceId: id, format, includeAttachments });
} }
setIncludeChildren(false);
setIncludeAttachments(true);
onClose(); onClose();
} catch (err) { } catch (err) {
notifications.show({ notifications.show({
@ -96,6 +99,18 @@ export default function ExportModal({
checked={includeChildren} checked={includeChildren}
/> />
</Group> </Group>
<Group justify="space-between" wrap="nowrap" mt="md">
<div>
<Text size="md">{t("Include attachments")}</Text>
</div>
<Switch
onChange={(event) =>
setIncludeAttachments(event.currentTarget.checked)
}
checked={includeAttachments}
/>
</Group>
</> </>
)} )}

View File

@ -1,105 +0,0 @@
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";
import { ExportFormat } from "@/features/page/types/page.types.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
interface PageExportModalProps {
pageId: string;
open: boolean;
onClose: () => void;
}
export default function PageExportModal({
pageId,
open,
onClose,
}: PageExportModalProps) {
const { t } = useTranslation();
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const handleExport = async () => {
try {
await exportPage({ pageId: pageId, format });
onClose();
} catch (err) {
notifications.show({
message: t("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}>{t("Export page")}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">{t("Format")}</Text>
</div>
<ExportFormatSelection format={format} onChange={handleChange} />
</Group>
<Group justify="space-between" wrap="nowrap" pt="md">
<div>
<Text size="md">{t("Include subpages")}</Text>
</div>
<Switch defaultChecked />
</Group>
<Group justify="center" mt="md">
<Button onClick={onClose} variant="default">
{t("Cancel")}
</Button>
<Button onClick={handleExport}>{t("Export")}</Button>
</Group>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
interface ExportFormatSelection {
format: ExportFormat;
onChange: (value: string) => void;
}
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
const { t } = useTranslation();
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={t("Select export format")}
/>
);
}

View File

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

View File

@ -23,6 +23,10 @@ export class ExportPageDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
includeChildren?: boolean; includeChildren?: boolean;
@IsOptional()
@IsBoolean()
includeAttachments?: boolean;
} }
export class ExportSpaceDto { export class ExportSpaceDto {

View File

@ -55,40 +55,22 @@ export class ExportController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
const fileExt = getExportExtension(dto.format); const zipFileBuffer = await this.exportService.exportPages(
const fileName = sanitize(page.title || 'untitled') + fileExt;
if (dto.includeChildren) {
const zipFileBuffer = await this.exportService.exportPageWithChildren(
dto.pageId, dto.pageId,
dto.format, dto.format,
dto.includeAttachments,
dto.includeChildren,
); );
const newName = path.parse(fileName).name + '.zip'; const fileName = sanitize(page.title || 'untitled') + '.zip';
res.headers({ res.headers({
'Content-Type': 'application/zip', 'Content-Type': 'application/zip',
'Content-Disposition':
'attachment; filename="' + encodeURIComponent(newName) + '"',
});
res.send(zipFileBuffer);
return;
}
const rawContent = await this.exportService.exportPage(
dto.format,
page,
true,
);
res.headers({
'Content-Type': getMimeType(fileExt),
'Content-Disposition': 'Content-Disposition':
'attachment; filename="' + encodeURIComponent(fileName) + '"', 'attachment; filename="' + encodeURIComponent(fileName) + '"',
}); });
res.send(rawContent); res.send(zipFileBuffer);
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)

View File

@ -89,10 +89,28 @@ export class ExportService {
return; return;
} }
async exportPageWithChildren(pageId: string, format: string) { async exportPages(
const pages = await this.pageRepo.getPageAndDescendants(pageId, { pageId: string,
format: string,
includeAttachments: boolean,
includeChildren: boolean,
) {
let pages: Page[];
if (includeChildren) {
//@ts-ignore
pages = await this.pageRepo.getPageAndDescendants(pageId, {
includeContent: true, includeContent: true,
}); });
} else {
// Only fetch the single page when includeChildren is false
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
});
if (page){
pages = [page];
}
}
if (!pages || pages.length === 0) { if (!pages || pages.length === 0) {
throw new BadRequestException('No pages to export'); throw new BadRequestException('No pages to export');
@ -105,7 +123,7 @@ export class ExportService {
const tree = buildTree(pages as Page[]); const tree = buildTree(pages as Page[]);
const zip = new JSZip(); const zip = new JSZip();
await this.zipPages(tree, format, zip); await this.zipPages(tree, format, zip, includeAttachments);
const zipFile = zip.generateNodeStream({ const zipFile = zip.generateNodeStream({
type: 'nodebuffer', type: 'nodebuffer',
@ -168,7 +186,7 @@ export class ExportService {
tree: PageExportTree, tree: PageExportTree,
format: string, format: string,
zip: JSZip, zip: JSZip,
includeAttachments = true, includeAttachments: boolean,
): Promise<void> { ): Promise<void> {
const slugIdToPath: Record<string, string> = {}; const slugIdToPath: Record<string, string> = {};
@ -200,7 +218,8 @@ export class ExportService {
if (includeAttachments) { if (includeAttachments) {
await this.zipAttachments(updatedJsonContent, page.spaceId, folder); await this.zipAttachments(updatedJsonContent, page.spaceId, folder);
updatedJsonContent = updateAttachmentUrlsToLocalPaths(updatedJsonContent); updatedJsonContent =
updateAttachmentUrlsToLocalPaths(updatedJsonContent);
} }
const pageTitle = getPageTitle(page.title); const pageTitle = getPageTitle(page.title);