Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Document Export Functionality #238

Merged
merged 20 commits into from
Jul 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
57a64b4
Chore install dependencies required to implement export markdown
minai621 Jul 19, 2024
508f830
Feat Add DownloadMenu component for exporting markdown files
minai621 Jul 19, 2024
b65d558
remove documentNameStorage
minai621 Jul 20, 2024
68fd0db
Chore remove PreviewRefProvider
minai621 Jul 20, 2024
8ab135b
Chore install dependencies required to implement export markdown to t…
minai621 Jul 20, 2024
4b253e7
Feat file export implementation, supported extensions are pdf, markdo…
minai621 Jul 20, 2024
19046ed
Feat change the export of files implemented by the client to server A…
minai621 Jul 20, 2024
1d6e835
Fix improve file export handling in client
minai621 Jul 20, 2024
8bbdd51
Merge branch 'main' into feat-export-document
minai621 Jul 20, 2024
7a23dbd
Chore remove unused install in frontend
minai621 Jul 21, 2024
2734a4c
Refactor improving handle error
minai621 Jul 21, 2024
e0aec8c
Chore remove fs-extra dependency from backend package.json
minai621 Jul 22, 2024
4cedb04
Refactor remove try-catch block and use class-validator
minai621 Jul 22, 2024
a624af2
Refactor DownloadMenu component to use function syntax
minai621 Jul 22, 2024
9f0a7d7
Refactor change api calls to use react-query
minai621 Jul 22, 2024
1040b83
Refactor DownloadMenu component to fix typo
minai621 Jul 22, 2024
7af17ef
Refactor format executed
minai621 Jul 22, 2024
a35871c
Refactor useFileExport hook to optimize dependencies
minai621 Jul 22, 2024
d0ba0e2
Refactor useFileExport hook to handle file name match more efficiently
minai621 Jul 22, 2024
af14534
Refactor remove fs-extra dependency from backend package-lock.json
minai621 Jul 22, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,630 changes: 1,528 additions & 102 deletions backend/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
"@prisma/client": "^5.8.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"html-pdf-node": "^1.0.8",
"langchain": "^0.1.9",
"markdown-it": "^14.1.0",
"markdown-to-txt": "^2.0.1",
"moment": "^2.30.1",
"passport-github": "^1.1.0",
Expand Down
40 changes: 35 additions & 5 deletions backend/src/files/files.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { Body, Controller, Get, HttpRedirectResponse, Param, Post, Redirect } from "@nestjs/common";
import {
Body,
Controller,
Get,
HttpRedirectResponse,
Param,
Post,
Redirect,
StreamableFile,
} from "@nestjs/common";
import { ApiBody, ApiOkResponse, ApiOperation, ApiResponse } from "@nestjs/swagger";
import { Public } from "src/utils/decorators/auth.decorator";
import { CreateUploadPresignedUrlDto } from "./dto/create-upload-url.dto";
import { FilesService } from "./files.service";
import { ApiResponse, ApiOperation, ApiBody } from "@nestjs/swagger";
import { CreateUploadPresignedUrlResponse } from "./types/create-upload-url-response.type";
import { CreateUploadPresignedUrlDto } from "./dto/create-upload-url.dto";
import { Public } from "src/utils/decorators/auth.decorator";
import { ExportFileRequestBody, ExportFileResponse } from "./types/export-file.type";

@Controller("files")
export class FilesController {
Expand Down Expand Up @@ -32,7 +42,7 @@ export class FilesController {
@Redirect()
@ApiOperation({
summary: "Create Presigned URL for Download",
description: "Create rresigned URL for download",
description: "Create Presigned URL for download",
})
async createDownloadPresignedUrl(
@Param("file_name") fileKey: string
Expand All @@ -42,4 +52,24 @@ export class FilesController {
statusCode: 302,
};
}

@Public()
@Post("export-markdown")
@ApiOperation({
summary: "Export Markdown",
description: "Export Markdown to various formats",
})
@ApiBody({ type: ExportFileRequestBody })
@ApiOkResponse({ type: ExportFileResponse })
async exportMarkdown(
minai621 marked this conversation as resolved.
Show resolved Hide resolved
@Body() exportFileRequestBody: ExportFileRequestBody
): Promise<StreamableFile> {
const { fileContent, mimeType, fileName } =
await this.filesService.exportMarkdown(exportFileRequestBody);

return new StreamableFile(fileContent, {
type: mimeType,
disposition: `attachment; filename="${fileName}"`,
});
}
minai621 marked this conversation as resolved.
Show resolved Hide resolved
}
60 changes: 56 additions & 4 deletions backend/src/files/files.service.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
UnprocessableEntityException,
} from "@nestjs/common";
import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { ConfigService } from "@nestjs/config";
import { generateRandomKey } from "src/utils/functions/random-string";
import { PrismaService } from "src/db/prisma.service";
import { Workspace } from "@prisma/client";
import * as htmlPdf from "html-pdf-node";
import * as MarkdownIt from "markdown-it";
import { PrismaService } from "src/db/prisma.service";
import { generateRandomKey } from "src/utils/functions/random-string";
import { CreateUploadPresignedUrlResponse } from "./types/create-upload-url-response.type";
import { ExportFileRequestBody, ExportFileResponse } from "./types/export-file.type";

@Injectable()
export class FilesService {
private s3Client: S3Client;
private readonly markdown = new MarkdownIt();

constructor(
private configService: ConfigService,
Expand Down Expand Up @@ -68,4 +73,51 @@ export class FilesService {
throw new NotFoundException();
}
}

async exportMarkdown(
exportFileRequestBody: ExportFileRequestBody
): Promise<ExportFileResponse> {
const { exportType, content, fileName } = exportFileRequestBody;

switch (exportType) {
case "markdown":
return this.exportToMarkdown(content, fileName);
case "html":
return this.exportToHtml(content, fileName);
case "pdf":
return this.exportToPdf(content, fileName);
default:
throw new BadRequestException("Invalid export type");
}
}

private async exportToMarkdown(content: string, fileName: string): Promise<ExportFileResponse> {
return {
fileContent: Buffer.from(content),
mimeType: "text/markdown",
fileName: `${fileName}.md`,
};
}

private async exportToHtml(content: string, fileName: string): Promise<ExportFileResponse> {
const html = this.markdown.render(content);
return {
fileContent: Buffer.from(html),
mimeType: "text/html",
fileName: `${fileName}.html`,
};
}

private async exportToPdf(content: string, fileName: string): Promise<ExportFileResponse> {
const html = this.markdown.render(content);
const options = { format: "A4" };
const file = { content: html };

const pdfBuffer = await htmlPdf.generatePdf(file, options);
return {
fileContent: pdfBuffer,
mimeType: "application/pdf",
fileName: `${fileName}.pdf`,
};
}
}
25 changes: 25 additions & 0 deletions backend/src/files/types/export-file.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsIn } from "class-validator";

export class ExportFileRequestBody {
@ApiProperty({ type: String, description: "export file type" })
@IsIn(["pdf", "html", "markdown"])
exportType: string;

@ApiProperty({ type: String, description: "markdown string" })
content: string;

@ApiProperty({ type: String, description: "File name" })
fileName: string;
}

export class ExportFileResponse {
@ApiProperty({ type: Buffer, description: "File content" })
fileContent: Buffer;

@ApiProperty({ type: String, description: "File mime type" })
mimeType: string;

@ApiProperty({ type: String, description: "File name" })
fileName: string;
}
6 changes: 3 additions & 3 deletions frontend/src/components/cards/DocumentCard.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import moment from "moment";
import { Card, CardActionArea, CardContent, Stack, Typography } from "@mui/material";
import AccessTimeIcon from "@mui/icons-material/AccessTime";
import { Document } from "../../hooks/api/types/document.d";
import { Card, CardActionArea, CardContent, Stack, Typography } from "@mui/material";
import moment from "moment";
import { useNavigate, useParams } from "react-router-dom";
import { Document } from "../../hooks/api/types/document.d";

interface DocumentCardProps {
document: Document;
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/components/common/DownloadMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { SaveAlt as SaveAltIcon } from "@mui/icons-material";
import { IconButton, Menu, MenuItem, Paper } from "@mui/material";
import { MouseEvent, useState } from "react";
import { useFileExport } from "../../hooks/useFileExport";

function DownloadMenu() {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);

const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};

const handleClose = () => {
setAnchorEl(null);
};

const { handleExportToPDF, handleExportToHTML, handleExportToMarkdown } = useFileExport();

return (
<Paper>
<IconButton aria-controls="download-menu" aria-haspopup="true" onClick={handleClick}>
<SaveAltIcon />
</IconButton>
<Menu
id="download-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}
>
<MenuItem onClick={handleExportToPDF}>Download as PDF</MenuItem>
<MenuItem onClick={handleExportToHTML}>Download as HTML</MenuItem>
<MenuItem onClick={handleExportToMarkdown}>Download as Markdown</MenuItem>
</Menu>
</Paper>
);
}

export default DownloadMenu;
20 changes: 10 additions & 10 deletions frontend/src/components/editor/Preview.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import MarkdownPreview from "@uiw/react-markdown-preview";
import { useCurrentTheme } from "../../hooks/useCurrentTheme";
import { useSelector } from "react-redux";
import { selectEditor } from "../../store/editorSlice";
import { CircularProgress, Stack } from "@mui/material";
import { useEffect, useState } from "react";
import "./editor.css";
import { addSoftLineBreak } from "../../utils/document";
import MarkdownPreview from "@uiw/react-markdown-preview";
import katex from "katex";
import "katex/dist/katex.min.css";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import rehypeExternalLinks from "rehype-external-links";
import rehypeKatex from "rehype-katex";
import { getCodeString } from "rehype-rewrite";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import rehypeExternalLinks from "rehype-external-links";
import "katex/dist/katex.min.css";
import { useCurrentTheme } from "../../hooks/useCurrentTheme";
import { selectEditor } from "../../store/editorSlice";
import { addSoftLineBreak } from "../../utils/document";
import "./editor.css";

function Preview() {
const currentTheme = useCurrentTheme();
Expand Down
22 changes: 12 additions & 10 deletions frontend/src/components/headers/DocumentHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import EditIcon from "@mui/icons-material/Edit";
import VerticalSplitIcon from "@mui/icons-material/VerticalSplit";
import VisibilityIcon from "@mui/icons-material/Visibility";
import {
AppBar,
Avatar,
Expand All @@ -16,20 +20,17 @@ import {
ListItemAvatar,
ListItemText,
} from "@mui/material";
import EditIcon from "@mui/icons-material/Edit";
import VerticalSplitIcon from "@mui/icons-material/VerticalSplit";
import VisibilityIcon from "@mui/icons-material/Visibility";
import { useDispatch, useSelector } from "react-redux";
import { EditorModeType, selectEditor, setMode } from "../../store/editorSlice";
import ThemeButton from "../common/ThemeButton";
import ShareButton from "../common/ShareButton";
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { useList } from "react-use";
import { ActorID } from "yorkie-js-sdk";
import { YorkieCodeMirrorPresenceType } from "../../utils/yorkie/yorkieSync";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import { useNavigate } from "react-router-dom";
import { EditorModeType, selectEditor, setMode } from "../../store/editorSlice";
import { selectWorkspace } from "../../store/workspaceSlice";
import { YorkieCodeMirrorPresenceType } from "../../utils/yorkie/yorkieSync";
import DownloadMenu from "../common/DownloadMenu";
import ShareButton from "../common/ShareButton";
import ThemeButton from "../common/ThemeButton";

function DocumentHeader() {
const dispatch = useDispatch();
Expand Down Expand Up @@ -147,6 +148,7 @@ function DocumentHeader() {
</ToggleButtonGroup>
)}
</Paper>
<DownloadMenu />
</Stack>
<Stack direction="row" alignItems="center" gap={1}>
<AvatarGroup max={4} onClick={handleOpenPopover}>
Expand Down
32 changes: 31 additions & 1 deletion frontend/src/hooks/api/file.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { UploadFileRequest, CreateUploadUrlRequest, CreateUploadUrlResponse } from "./types/file";
import {
CreateUploadUrlRequest,
CreateUploadUrlResponse,
ExportFileRequest,
UploadFileRequest,
} from "./types/file";

export const useCreateUploadUrlMutation = () => {
return useMutation({
Expand All @@ -24,3 +29,28 @@ export const useUploadFileMutation = () => {
},
});
};

export const useExportFileMutation = () => {
return useMutation({
mutationFn: async (data: ExportFileRequest) => {
const { exportType, content, fileName } = data;

const res = await axios.post(
"/files/export-markdown",
{
exportType,
content,
fileName,
},
{
responseType: "blob",
headers: {
Accept: "application/octet-stream",
},
}
);

return res;
},
});
};
minai621 marked this conversation as resolved.
Show resolved Hide resolved
6 changes: 6 additions & 0 deletions frontend/src/hooks/api/types/file.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ export class UploadFileRequest {
url: string;
file: File;
}

export class ExportFileRequest {
exportType: string;
content: string;
fileName: string;
}
Loading