feat: Individual page export in Markdown and HTML formats (#80)

* fix maths node

* render default html width

* Add page export module
* with support for html and markdown exports

* Page export UI
* Add PDF print too

* remove unused import
This commit is contained in:
Philip Okugbe
2024-07-12 14:45:09 +01:00
committed by GitHub
parent b43de81013
commit f388540293
30 changed files with 782 additions and 76 deletions

View File

@ -12,6 +12,7 @@ import { QueueModule } from './integrations/queue/queue.module';
import { StaticModule } from './integrations/static/static.module';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { HealthModule } from './integrations/health/health.module';
import { ExportModule } from './integrations/export/export.module';
@Module({
imports: [
@ -23,6 +24,7 @@ import { HealthModule } from './integrations/health/health.module';
QueueModule,
StaticModule,
HealthModule,
ExportModule,
StorageModule.forRootAsync({
imports: [EnvironmentModule],
}),

View File

@ -60,7 +60,7 @@ export const tiptapExtensions = [
Callout,
] as any;
export function jsonToHtml(tiptapJson: JSONContent) {
export function jsonToHtml(tiptapJson: any) {
return generateHTML(tiptapJson, tiptapExtensions);
}

View File

@ -0,0 +1,26 @@
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
export enum ExportFormat {
HTML = 'html',
Markdown = 'markdown',
}
export class ExportPageDto {
@IsString()
@IsNotEmpty()
pageId: string;
@IsString()
@IsIn(['html', 'markdown'])
format: ExportFormat;
@IsOptional()
@IsBoolean()
includeFiles?: boolean;
}

View File

@ -0,0 +1,69 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
Res,
UseGuards,
} from '@nestjs/common';
import { ExportService } from './export.service';
import { ExportPageDto } from './dto/export-dto';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { User } from '@docmost/db/types/entity.types';
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../core/casl/interfaces/space-ability.type';
import { FastifyReply } from 'fastify';
import { sanitize } from 'sanitize-filename-ts';
import { getExportExtension } from './utils';
import { getMimeType } from '../../common/helpers';
@Controller()
export class ImportController {
constructor(
private readonly importService: ExportService,
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('pages/export')
async exportPage(
@Body() dto: ExportPageDto,
@AuthUser() user: User,
@Res() res: FastifyReply,
) {
const page = await this.pageRepo.findById(dto.pageId, {
includeContent: true,
});
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const rawContent = await this.importService.exportPage(dto.format, page);
const fileExt = getExportExtension(dto.format);
const fileName = sanitize(page.title || 'Untitled') + fileExt;
res.headers({
'Content-Type': getMimeType(fileExt),
'Content-Disposition': 'attachment; filename="' + fileName + '"',
});
res.send(rawContent);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ExportService } from './export.service';
import { ImportController } from './export.controller';
@Module({
providers: [ExportService],
controllers: [ImportController],
})
export class ExportModule {}

View File

@ -0,0 +1,34 @@
import { Injectable } 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';
@Injectable()
export class ExportService {
async exportPage(format: string, page: Page) {
const titleNode = {
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: page.title }],
};
let prosemirrorJson: any = page.content || { type: 'doc', content: [] };
if (page.title) {
prosemirrorJson.content.unshift(titleNode);
}
const pageHtml = jsonToHtml(prosemirrorJson);
if (format === ExportFormat.HTML) {
return `<!DOCTYPE html><html><head><title>${page.title}</title></head><body>${pageHtml}</body></html>`;
}
if (format === ExportFormat.Markdown) {
return turndown(pageHtml);
}
return;
}
}

View File

@ -0,0 +1,100 @@
import * as TurndownService from '@joplin/turndown';
import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm';
export function turndown(html: string): string {
const turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
hr: '---',
bulletListMarker: '-',
});
const tables = TurndownPluginGfm.tables;
const strikethrough = TurndownPluginGfm.strikethrough;
const highlightedCodeBlock = TurndownPluginGfm.highlightedCodeBlock;
turndownService.use([
tables,
strikethrough,
highlightedCodeBlock,
taskList,
callout,
toggleListTitle,
toggleListBody,
listParagraph,
]);
return turndownService.turndown(html).replaceAll('<br>', ' ');
}
function listParagraph(turndownService: TurndownService) {
turndownService.addRule('paragraph', {
filter: ['p'],
replacement: (content: any, node: HTMLInputElement) => {
if (node.parentElement?.nodeName === 'LI') {
return content;
}
return `\n\n${content}\n\n`;
},
});
}
function callout(turndownService: TurndownService) {
turndownService.addRule('callout', {
filter: function (node: HTMLInputElement) {
return (
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout'
);
},
replacement: function (content: any, node: HTMLInputElement) {
const calloutType = node.getAttribute('data-callout-type');
return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`;
},
});
}
function taskList(turndownService: TurndownService) {
turndownService.addRule('taskListItem', {
filter: function (node: HTMLInputElement) {
return (
node.getAttribute('data-type') === 'taskItem' &&
node.parentNode.nodeName === 'UL'
);
},
replacement: function (content: any, node: HTMLInputElement) {
const checkbox = node.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
const isChecked = checkbox.checked;
return `- ${isChecked ? '[x]' : '[ ]'} ${content.trim()} \n`;
},
});
}
function toggleListTitle(turndownService: TurndownService) {
turndownService.addRule('toggleListTitle', {
filter: function (node: HTMLInputElement) {
return (
node.nodeName === 'SUMMARY' && node.parentNode.nodeName === 'DETAILS'
);
},
replacement: function (content: any, node: HTMLInputElement) {
return '- ' + content;
},
});
}
function toggleListBody(turndownService: TurndownService) {
turndownService.addRule('toggleListContent', {
filter: function (node: HTMLInputElement) {
return (
node.getAttribute('data-type') === 'detailsContent' &&
node.parentNode.nodeName === 'DETAILS'
);
},
replacement: function (content: any, node: HTMLInputElement) {
return ` ${content.replace(/\n/g, '\n ')} `;
},
});
}

View File

@ -0,0 +1,12 @@
import { ExportFormat } from './dto/export-dto';
export function getExportExtension(format: string) {
if (format === ExportFormat.HTML) {
return '.html';
}
if (format === ExportFormat.Markdown) {
return '.md';
}
return;
}