diff --git a/client/components/build/Center/ArtboardController.tsx b/client/components/build/Center/ArtboardController.tsx index b68e397b..66b5283a 100644 --- a/client/components/build/Center/ArtboardController.tsx +++ b/client/components/build/Center/ArtboardController.tsx @@ -13,6 +13,7 @@ import { } from '@mui/icons-material'; import { ButtonBase, Divider, Tooltip, useMediaQuery, useTheme } from '@mui/material'; import clsx from 'clsx'; +import dayjs from 'dayjs'; import { get } from 'lodash'; import { useTranslation } from 'next-i18next'; import toast from 'react-hot-toast'; @@ -67,8 +68,9 @@ const ArtboardController: React.FC = ({ zoomIn, zoomOut, c const slug = get(resume, 'slug'); 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); }; diff --git a/client/components/build/RightSidebar/sections/Export.tsx b/client/components/build/RightSidebar/sections/Export.tsx index d8a3df98..7e61b651 100644 --- a/client/components/build/RightSidebar/sections/Export.tsx +++ b/client/components/build/RightSidebar/sections/Export.tsx @@ -1,5 +1,6 @@ import { PictureAsPdf, Schema } from '@mui/icons-material'; import { List, ListItem, ListItemButton, ListItemText } from '@mui/material'; +import dayjs from 'dayjs'; import get from 'lodash/get'; import pick from 'lodash/pick'; import { useTranslation } from 'next-i18next'; @@ -45,8 +46,9 @@ const Export = () => { const slug = get(resume, 'slug'); 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); }; diff --git a/client/pages/[username]/[slug]/index.tsx b/client/pages/[username]/[slug]/index.tsx index 41cb6e7d..e4b370a5 100644 --- a/client/pages/[username]/[slug]/index.tsx +++ b/client/pages/[username]/[slug]/index.tsx @@ -2,6 +2,7 @@ import { Download, Downloading } from '@mui/icons-material'; import { ButtonBase } from '@mui/material'; import { Resume } from '@reactive-resume/schema'; import clsx from 'clsx'; +import dayjs from 'dayjs'; import download from 'downloadjs'; import get from 'lodash/get'; import isEmpty from 'lodash/isEmpty'; @@ -96,7 +97,9 @@ const Preview: NextPage = ({ username, slug, resume: initialData }) => { const handleDownload = async () => { 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); } catch { diff --git a/client/pages/r/[shortId].tsx b/client/pages/r/[shortId].tsx index da454333..6aa38eb2 100644 --- a/client/pages/r/[shortId].tsx +++ b/client/pages/r/[shortId].tsx @@ -2,6 +2,7 @@ import { Download, Downloading } from '@mui/icons-material'; import { ButtonBase } from '@mui/material'; import { Resume } from '@reactive-resume/schema'; import clsx from 'clsx'; +import dayjs from 'dayjs'; import download from 'downloadjs'; import get from 'lodash/get'; import isEmpty from 'lodash/isEmpty'; @@ -69,7 +70,7 @@ const Preview: NextPage = ({ shortId }) => { const handleDownload = async () => { 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); } catch { diff --git a/client/services/printer.ts b/client/services/printer.ts index 743384b5..8e732518 100644 --- a/client/services/printer.ts +++ b/client/services/printer.ts @@ -3,7 +3,9 @@ import axios from './axios'; export type PrintResumeAsPdfParams = { username: string; slug: string; + lastUpdated: string; }; export const printResumeAsPdf = (printResumeAsPdfParams: PrintResumeAsPdfParams): Promise => - 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); diff --git a/client/store/index.ts b/client/store/index.ts index aa095787..11656f47 100644 --- a/client/store/index.ts +++ b/client/store/index.ts @@ -34,7 +34,7 @@ const store = configureStore({ }, }); -sagaMiddleware.run(syncSaga); +sagaMiddleware.run(() => syncSaga(store.dispatch)); export const persistor = persistStore(store); diff --git a/client/store/sagas/sync.ts b/client/store/sagas/sync.ts index 6d03b850..a92bb99c 100644 --- a/client/store/sagas/sync.ts +++ b/client/store/sagas/sync.ts @@ -3,7 +3,7 @@ import debounce from 'lodash/debounce'; import { select, takeLatest } from 'redux-saga/effects'; import { updateResume } from '@/services/resume'; -import { RootState } from '@/store/index'; +import { AppDispatch, RootState } from '@/store/index'; import { addItem, @@ -12,23 +12,24 @@ import { deleteSection, duplicateItem, editItem, + setResume, setResumeState, } from '../resume/resumeSlice'; 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); - debouncedSync(resume); + debouncedSync(resume, dispatch); } -function* syncSaga() { +function* syncSaga(dispatch: AppDispatch) { yield takeLatest( [setResumeState, addItem, editItem, duplicateItem, deleteItem, addSection, deleteSection], - handleSync + () => handleSync(dispatch) ); } diff --git a/server/src/printer/printer.controller.ts b/server/src/printer/printer.controller.ts index bacf305d..9426f853 100644 --- a/server/src/printer/printer.controller.ts +++ b/server/src/printer/printer.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param } from '@nestjs/common'; +import { Controller, Get, Param, Query } from '@nestjs/common'; import { PrinterService } from './printer.service'; @@ -7,7 +7,7 @@ export class PrinterController { constructor(private readonly printerService: PrinterService) {} @Get('/:username/:slug') - printAsPdf(@Param('username') username: string, @Param('slug') slug: string): Promise { - return this.printerService.printAsPdf(username, slug); + printAsPdf(@Param('username') username: string, @Param('slug') slug: string, @Query('lastUpdated') lastUpdated: string): Promise { + return this.printerService.printAsPdf(username, slug, lastUpdated); } } diff --git a/server/src/printer/printer.service.ts b/server/src/printer/printer.service.ts index 8dfa2e9d..332cbeba 100644 --- a/server/src/printer/printer.service.ts +++ b/server/src/printer/printer.service.ts @@ -1,9 +1,7 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { SchedulerRegistry } from '@nestjs/schedule'; import { PageConfig } from '@reactive-resume/schema'; -import { mkdir, unlink, writeFile } from 'fs/promises'; -import { nanoid } from 'nanoid'; +import { access, mkdir, readdir, unlink, writeFile } from 'fs/promises'; import { join } from 'path'; import { PDFDocument } from 'pdf-lib'; import { Browser, chromium } from 'playwright-chromium'; @@ -14,7 +12,7 @@ export const DELETION_TIME = 10 * 1000; // 10 seconds export class PrinterService implements OnModuleInit, OnModuleDestroy { private browser: Browser; - constructor(private readonly schedulerRegistry: SchedulerRegistry, private readonly configService: ConfigService) {} + constructor(private readonly configService: ConfigService) {} async onModuleInit() { this.browser = await chromium.launch({ @@ -26,68 +24,70 @@ export class PrinterService implements OnModuleInit, OnModuleDestroy { await this.browser.close(); } - async printAsPdf(username: string, slug: string): Promise { - const url = this.configService.get('app.url'); + async printAsPdf(username: string, slug: string, lastUpdated: string): Promise { const serverUrl = this.configService.get('app.serverUrl'); - const secretKey = this.configService.get('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 filename = `RxResume_PDFExport_${nanoid()}.pdf`; + const filename = `RxResume_PDFExport_${username}_${slug}_${lastUpdated}.pdf`; const publicUrl = `${serverUrl}/assets/exports/${filename}`; - for (let index = 0; index < resumePages.length; index++) { - await page.evaluate((page) => (document.body.innerHTML = page.innerHTML), resumePages[index]); - - const buffer = await page.pdf({ - printBackground: true, - height: resumePages[index].height, - width: pageFormat === 'A4' ? '210mm' : '216mm', + try { // check if file already exists + await access(join(directory, filename)); + } catch { // create file as it doesn't exist + // delete old files + await readdir(directory).then(async (files) => { + await Promise.all(files.map(async (file) => { + if (file.startsWith(`RxResume_PDFExport_${username}_${slug}`)) { + await unlink(join(directory, file)); + } + })); }); - const pageDoc = await PDFDocument.load(buffer); - const copiedPages = await pdf.copyPages(pageDoc, [0]); + const url = this.configService.get('app.url'); + const secretKey = this.configService.get('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 }); - await writeFile(join(directory, filename), pdfBytes); + const resumePages = await page.$$eval('[data-page]', (pages) => + pages.map((page, index) => ({ + pageNumber: index + 1, + innerHTML: page.innerHTML, + height: page.clientHeight, + })) + ); - // Delete PDF artifacts after DELETION_TIME ms - const timeout = setTimeout(async () => { - try { - await unlink(join(directory, filename)); + const pdf = await PDFDocument.create(); - this.schedulerRegistry.deleteTimeout(`delete-${filename}`); - } catch { - // pass through + for (let index = 0; index < resumePages.length; index++) { + await page.evaluate((page) => (document.body.innerHTML = page.innerHTML), resumePages[index]); + + 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; }