mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
223 lines
5.8 KiB
TypeScript
223 lines
5.8 KiB
TypeScript
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 `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>${getPageTitle(page.title)}</title>
|
|
</head>
|
|
<body>${pageHtml}</body>
|
|
</html>`;
|
|
}
|
|
|
|
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<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);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|