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

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