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: getPageTitle(page.title) }], }; let prosemirrorJson: any = getProsemirrorContent(page.content); if (page.title) { prosemirrorJson.content.unshift(titleNode); } const pageHtml = jsonToHtml(prosemirrorJson); if (format === ExportFormat.HTML) { return ` ${getPageTitle(page.title)} ${pageHtml} `; } if (format === ExportFormat.Markdown) { return turndown(pageHtml); } 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', 'pages.spaceId' ]) .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.zip`; return { fileBuffer: zipFile, fileName, }; } async zipPages( tree: PageExportTree, format: string, zip: JSZip, includeAttachments = true, ): Promise { const slugIdToPath: Record = {}; 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); } }), ); } } }