mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-13 16:22:59 +10:00
Add PDF file caching
This commit is contained in:
@ -13,6 +13,7 @@ import {
|
|||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { ButtonBase, Divider, Tooltip, useMediaQuery, useTheme } from '@mui/material';
|
import { ButtonBase, Divider, Tooltip, useMediaQuery, useTheme } from '@mui/material';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import { get } from 'lodash';
|
import { get } from 'lodash';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
@ -67,8 +68,9 @@ const ArtboardController: React.FC<ReactZoomPanPinchRef> = ({ zoomIn, zoomOut, c
|
|||||||
|
|
||||||
const slug = get(resume, 'slug');
|
const slug = get(resume, 'slug');
|
||||||
const username = get(resume, 'user.username');
|
const username = get(resume, 'user.username');
|
||||||
|
const updatedAt = get(resume, 'updatedAt');
|
||||||
|
|
||||||
const url = await mutateAsync({ username, slug });
|
const url = await mutateAsync({ username, slug, lastUpdated: dayjs(updatedAt).unix().toString() });
|
||||||
|
|
||||||
download(url);
|
download(url);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { PictureAsPdf, Schema } from '@mui/icons-material';
|
import { PictureAsPdf, Schema } from '@mui/icons-material';
|
||||||
import { List, ListItem, ListItemButton, ListItemText } from '@mui/material';
|
import { List, ListItem, ListItemButton, ListItemText } from '@mui/material';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
import pick from 'lodash/pick';
|
import pick from 'lodash/pick';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
@ -45,8 +46,9 @@ const Export = () => {
|
|||||||
|
|
||||||
const slug = get(resume, 'slug');
|
const slug = get(resume, 'slug');
|
||||||
const username = get(resume, 'user.username');
|
const username = get(resume, 'user.username');
|
||||||
|
const updatedAt = get(resume, 'updatedAt');
|
||||||
|
|
||||||
const url = await mutateAsync({ username, slug });
|
const url = await mutateAsync({ username, slug, lastUpdated: dayjs(updatedAt).unix().toString() });
|
||||||
|
|
||||||
download(url);
|
download(url);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Download, Downloading } from '@mui/icons-material';
|
|||||||
import { ButtonBase } from '@mui/material';
|
import { ButtonBase } from '@mui/material';
|
||||||
import { Resume } from '@reactive-resume/schema';
|
import { Resume } from '@reactive-resume/schema';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import download from 'downloadjs';
|
import download from 'downloadjs';
|
||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
import isEmpty from 'lodash/isEmpty';
|
import isEmpty from 'lodash/isEmpty';
|
||||||
@ -96,7 +97,9 @@ const Preview: NextPage<Props> = ({ username, slug, resume: initialData }) => {
|
|||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
const url = await mutateAsync({ username, slug });
|
const updatedAt = get(resume, 'updatedAt');
|
||||||
|
|
||||||
|
const url = await mutateAsync({ username, slug, lastUpdated: dayjs(updatedAt).unix().toString() });
|
||||||
|
|
||||||
download(url);
|
download(url);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Download, Downloading } from '@mui/icons-material';
|
|||||||
import { ButtonBase } from '@mui/material';
|
import { ButtonBase } from '@mui/material';
|
||||||
import { Resume } from '@reactive-resume/schema';
|
import { Resume } from '@reactive-resume/schema';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import download from 'downloadjs';
|
import download from 'downloadjs';
|
||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
import isEmpty from 'lodash/isEmpty';
|
import isEmpty from 'lodash/isEmpty';
|
||||||
@ -69,7 +70,7 @@ const Preview: NextPage<Props> = ({ shortId }) => {
|
|||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
const url = await mutateAsync({ username: resume.user.username, slug: resume.slug });
|
const url = await mutateAsync({ username: resume.user.username, slug: resume.slug, lastUpdated: dayjs(resume.updatedAt).unix().toString() });
|
||||||
|
|
||||||
download(url);
|
download(url);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -3,7 +3,9 @@ import axios from './axios';
|
|||||||
export type PrintResumeAsPdfParams = {
|
export type PrintResumeAsPdfParams = {
|
||||||
username: string;
|
username: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
lastUpdated: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const printResumeAsPdf = (printResumeAsPdfParams: PrintResumeAsPdfParams): Promise<string> =>
|
export const printResumeAsPdf = (printResumeAsPdfParams: PrintResumeAsPdfParams): Promise<string> =>
|
||||||
axios.get(`/printer/${printResumeAsPdfParams.username}/${printResumeAsPdfParams.slug}`).then((res) => res.data);
|
axios.get(`/printer/${printResumeAsPdfParams.username}/${printResumeAsPdfParams.slug}?lastUpdated=${printResumeAsPdfParams.lastUpdated}`)
|
||||||
|
.then((res) => res.data);
|
||||||
|
|||||||
@ -34,7 +34,7 @@ const store = configureStore({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
sagaMiddleware.run(syncSaga);
|
sagaMiddleware.run(() => syncSaga(store.dispatch));
|
||||||
|
|
||||||
export const persistor = persistStore(store);
|
export const persistor = persistStore(store);
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import debounce from 'lodash/debounce';
|
|||||||
import { select, takeLatest } from 'redux-saga/effects';
|
import { select, takeLatest } from 'redux-saga/effects';
|
||||||
|
|
||||||
import { updateResume } from '@/services/resume';
|
import { updateResume } from '@/services/resume';
|
||||||
import { RootState } from '@/store/index';
|
import { AppDispatch, RootState } from '@/store/index';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
addItem,
|
addItem,
|
||||||
@ -12,23 +12,24 @@ import {
|
|||||||
deleteSection,
|
deleteSection,
|
||||||
duplicateItem,
|
duplicateItem,
|
||||||
editItem,
|
editItem,
|
||||||
|
setResume,
|
||||||
setResumeState,
|
setResumeState,
|
||||||
} from '../resume/resumeSlice';
|
} from '../resume/resumeSlice';
|
||||||
|
|
||||||
const DEBOUNCE_WAIT = 1000;
|
const DEBOUNCE_WAIT = 1000;
|
||||||
|
|
||||||
const debouncedSync = debounce((resume: Resume) => updateResume(resume), DEBOUNCE_WAIT);
|
const debouncedSync = debounce((resume: Resume, dispatch: AppDispatch) => updateResume(resume).then((resume) => dispatch(setResume(resume))), DEBOUNCE_WAIT);
|
||||||
|
|
||||||
function* handleSync() {
|
function* handleSync(dispatch: AppDispatch) {
|
||||||
const resume: Resume = yield select((state: RootState) => state.resume.present);
|
const resume: Resume = yield select((state: RootState) => state.resume.present);
|
||||||
|
|
||||||
debouncedSync(resume);
|
debouncedSync(resume, dispatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
function* syncSaga() {
|
function* syncSaga(dispatch: AppDispatch) {
|
||||||
yield takeLatest(
|
yield takeLatest(
|
||||||
[setResumeState, addItem, editItem, duplicateItem, deleteItem, addSection, deleteSection],
|
[setResumeState, addItem, editItem, duplicateItem, deleteItem, addSection, deleteSection],
|
||||||
handleSync
|
() => handleSync(dispatch)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Param } from '@nestjs/common';
|
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
|
|
||||||
import { PrinterService } from './printer.service';
|
import { PrinterService } from './printer.service';
|
||||||
|
|
||||||
@ -7,7 +7,7 @@ export class PrinterController {
|
|||||||
constructor(private readonly printerService: PrinterService) {}
|
constructor(private readonly printerService: PrinterService) {}
|
||||||
|
|
||||||
@Get('/:username/:slug')
|
@Get('/:username/:slug')
|
||||||
printAsPdf(@Param('username') username: string, @Param('slug') slug: string): Promise<string> {
|
printAsPdf(@Param('username') username: string, @Param('slug') slug: string, @Query('lastUpdated') lastUpdated: string): Promise<string> {
|
||||||
return this.printerService.printAsPdf(username, slug);
|
return this.printerService.printAsPdf(username, slug, lastUpdated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
|
||||||
import { PageConfig } from '@reactive-resume/schema';
|
import { PageConfig } from '@reactive-resume/schema';
|
||||||
import { mkdir, unlink, writeFile } from 'fs/promises';
|
import { access, mkdir, readdir, unlink, writeFile } from 'fs/promises';
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
import { Browser, chromium } from 'playwright-chromium';
|
import { Browser, chromium } from 'playwright-chromium';
|
||||||
@ -14,7 +12,7 @@ export const DELETION_TIME = 10 * 1000; // 10 seconds
|
|||||||
export class PrinterService implements OnModuleInit, OnModuleDestroy {
|
export class PrinterService implements OnModuleInit, OnModuleDestroy {
|
||||||
private browser: Browser;
|
private browser: Browser;
|
||||||
|
|
||||||
constructor(private readonly schedulerRegistry: SchedulerRegistry, private readonly configService: ConfigService) {}
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
this.browser = await chromium.launch({
|
this.browser = await chromium.launch({
|
||||||
@ -26,68 +24,70 @@ export class PrinterService implements OnModuleInit, OnModuleDestroy {
|
|||||||
await this.browser.close();
|
await this.browser.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
async printAsPdf(username: string, slug: string): Promise<string> {
|
async printAsPdf(username: string, slug: string, lastUpdated: string): Promise<string> {
|
||||||
const url = this.configService.get<string>('app.url');
|
|
||||||
const serverUrl = this.configService.get<string>('app.serverUrl');
|
const serverUrl = this.configService.get<string>('app.serverUrl');
|
||||||
const secretKey = this.configService.get<string>('app.secretKey');
|
|
||||||
|
|
||||||
const page = await this.browser.newPage();
|
|
||||||
|
|
||||||
await page.goto(`${url}/${username}/${slug}/printer?secretKey=${secretKey}`);
|
|
||||||
await page.waitForSelector('html.wf-active');
|
|
||||||
|
|
||||||
const pageFormat: PageConfig['format'] = await page.$$eval(
|
|
||||||
'[data-page]',
|
|
||||||
(pages) => pages[0].getAttribute('data-format') as PageConfig['format']
|
|
||||||
);
|
|
||||||
|
|
||||||
const resumePages = await page.$$eval('[data-page]', (pages) =>
|
|
||||||
pages.map((page, index) => ({
|
|
||||||
pageNumber: index + 1,
|
|
||||||
innerHTML: page.innerHTML,
|
|
||||||
height: page.clientHeight,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const pdf = await PDFDocument.create();
|
|
||||||
const directory = join(__dirname, '..', 'assets/exports');
|
const directory = join(__dirname, '..', 'assets/exports');
|
||||||
const filename = `RxResume_PDFExport_${nanoid()}.pdf`;
|
const filename = `RxResume_PDFExport_${username}_${slug}_${lastUpdated}.pdf`;
|
||||||
const publicUrl = `${serverUrl}/assets/exports/${filename}`;
|
const publicUrl = `${serverUrl}/assets/exports/${filename}`;
|
||||||
|
|
||||||
for (let index = 0; index < resumePages.length; index++) {
|
try { // check if file already exists
|
||||||
await page.evaluate((page) => (document.body.innerHTML = page.innerHTML), resumePages[index]);
|
await access(join(directory, filename));
|
||||||
|
} catch { // create file as it doesn't exist
|
||||||
const buffer = await page.pdf({
|
// delete old files
|
||||||
printBackground: true,
|
await readdir(directory).then(async (files) => {
|
||||||
height: resumePages[index].height,
|
await Promise.all(files.map(async (file) => {
|
||||||
width: pageFormat === 'A4' ? '210mm' : '216mm',
|
if (file.startsWith(`RxResume_PDFExport_${username}_${slug}`)) {
|
||||||
|
await unlink(join(directory, file));
|
||||||
|
}
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageDoc = await PDFDocument.load(buffer);
|
const url = this.configService.get<string>('app.url');
|
||||||
const copiedPages = await pdf.copyPages(pageDoc, [0]);
|
const secretKey = this.configService.get<string>('app.secretKey');
|
||||||
|
|
||||||
copiedPages.forEach((copiedPage) => pdf.addPage(copiedPage));
|
const page = await this.browser.newPage();
|
||||||
}
|
|
||||||
|
|
||||||
await page.close();
|
await page.goto(`${url}/${username}/${slug}/printer?secretKey=${secretKey}`);
|
||||||
|
await page.waitForSelector('html.wf-active');
|
||||||
|
|
||||||
const pdfBytes = await pdf.save();
|
const pageFormat: PageConfig['format'] = await page.$$eval(
|
||||||
|
'[data-page]',
|
||||||
|
(pages) => pages[0].getAttribute('data-format') as PageConfig['format']
|
||||||
|
);
|
||||||
|
|
||||||
await mkdir(directory, { recursive: true });
|
const resumePages = await page.$$eval('[data-page]', (pages) =>
|
||||||
await writeFile(join(directory, filename), pdfBytes);
|
pages.map((page, index) => ({
|
||||||
|
pageNumber: index + 1,
|
||||||
|
innerHTML: page.innerHTML,
|
||||||
|
height: page.clientHeight,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
// Delete PDF artifacts after DELETION_TIME ms
|
const pdf = await PDFDocument.create();
|
||||||
const timeout = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await unlink(join(directory, filename));
|
|
||||||
|
|
||||||
this.schedulerRegistry.deleteTimeout(`delete-${filename}`);
|
for (let index = 0; index < resumePages.length; index++) {
|
||||||
} catch {
|
await page.evaluate((page) => (document.body.innerHTML = page.innerHTML), resumePages[index]);
|
||||||
// pass through
|
|
||||||
|
const buffer = await page.pdf({
|
||||||
|
printBackground: true,
|
||||||
|
height: resumePages[index].height,
|
||||||
|
width: pageFormat === 'A4' ? '210mm' : '216mm',
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageDoc = await PDFDocument.load(buffer);
|
||||||
|
const copiedPages = await pdf.copyPages(pageDoc, [0]);
|
||||||
|
|
||||||
|
copiedPages.forEach((copiedPage) => pdf.addPage(copiedPage));
|
||||||
}
|
}
|
||||||
}, DELETION_TIME);
|
|
||||||
|
|
||||||
this.schedulerRegistry.addTimeout(`delete-${filename}`, timeout);
|
await page.close();
|
||||||
|
|
||||||
|
const pdfBytes = await pdf.save();
|
||||||
|
|
||||||
|
await mkdir(directory, { recursive: true });
|
||||||
|
await writeFile(join(directory, filename), pdfBytes);
|
||||||
|
}
|
||||||
|
|
||||||
return publicUrl;
|
return publicUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user