mirror of
https://github.com/docmost/docmost.git
synced 2025-11-18 21:41:09 +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:
@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user