From 49610cb1c388691a5983a9768865da049f95c7f8 Mon Sep 17 00:00:00 2001
From: William Chong <6198816+williamchong@users.noreply.github.com>
Date: Wed, 28 Feb 2024 16:18:23 +0800
Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20insert=20iscn=20page=20into?=
=?UTF-8?q?=20epub=20on=20edit=20(#443)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* âš Add logic for inserting iscn page into epub
* âš Add UI logic for uploading modified epub files
* âš Fetch epub language when inserting page
* đ Default `isAddISCNPageToEpub` to false
* đ Fix epub cover ipfs hash mess up
* đ Fix arweave estimation was not refresh for modified file list
* đ Fix duplicated content fingerprint on update iscn
* đ Fix invalid item in epub check
---
components/IscnUploadForm.vue | 156 +++++++++++++++++++++--------
locales/en.json | 1 +
package.json | 3 +
pages/edit/_iscnId.vue | 1 +
utils/epub/asset.ts | 75 ++++++++++++++
utils/epub/iscn.ts | 182 ++++++++++++++++++++++++++++++++++
utils/misc.ts | 20 ++--
yarn.lock | 11 +-
8 files changed, 395 insertions(+), 54 deletions(-)
create mode 100644 utils/epub/asset.ts
create mode 100644 utils/epub/iscn.ts
diff --git a/components/IscnUploadForm.vue b/components/IscnUploadForm.vue
index 9fac9854..b380dde7 100644
--- a/components/IscnUploadForm.vue
+++ b/components/IscnUploadForm.vue
@@ -48,7 +48,7 @@
+
+ {{ $t('UploadForm.label.insertISCNPage') }}
+
Promise
@@ -281,6 +286,7 @@ export default class IscnUploadForm extends Vue {
isOpenWarningSnackbar = false
isOpenKeplr = true
isSizeExceeded = false
+ isAddISCNPageToEpub = false
uploadSizeLimit: number = UPLOAD_FILESIZE_MAX
uploadStatus: UploadStatus = '';
@@ -300,6 +306,7 @@ export default class IscnUploadForm extends Vue {
balance = new BigNumber(0)
epubMetadataList: any[] = []
+ modifiedEpubMap: any = {}
get formClasses() {
return [
@@ -369,12 +376,29 @@ export default class IscnUploadForm extends Vue {
}
}
+ get showAddISCNPageOption() {
+ return this.mode === MODE.EDIT && this.fileRecords.some(file => file.fileType === 'application/epub+zip')
+ }
+
+ get modifiedFileRecords() {
+ if (!this.isAddISCNPageToEpub || this.mode !== MODE.EDIT) return this.fileRecords
+ return this.fileRecords.map((record) => {
+ if (record.fileType === 'application/epub+zip') {
+ const modifiedEpubRecord = this.modifiedEpubMap[record.ipfsHash]
+ if (modifiedEpubRecord) {
+ return modifiedEpubRecord
+ }
+ }
+ return record
+ })
+ }
+
@Watch('error')
showWarning(errormsg: any) {
if (errormsg) this.isOpenWarningSnackbar = true
}
- @Watch('fileRecords')
+ @Watch('modifiedFileRecords')
async estimateArFee(fileRecords: any) {
if (fileRecords.length) {
this.uploadStatus = 'loading'
@@ -385,6 +409,32 @@ export default class IscnUploadForm extends Vue {
}
}
+ // eslint-disable-next-line class-methods-use-this
+ async getFileInfo(file: Blob) {
+ const fileBytes = (await fileToArrayBuffer(
+ file,
+ )) as unknown as ArrayBuffer
+ if (fileBytes) {
+ const [
+ fileSHA256,
+ imageType,
+ ipfsHash,
+ // eslint-disable-next-line no-await-in-loop
+ ] = await Promise.all([
+ digestFileSHA256(fileBytes),
+ readImageType(fileBytes),
+ Hash.of(Buffer.from(fileBytes)),
+ ])
+ return {
+ fileBytes,
+ fileSHA256,
+ imageType,
+ ipfsHash,
+ }
+ }
+ return null
+ }
+
async onFileUpload(event: DragEvent) {
logTrackerEvent(this, 'ISCNCreate', 'SelectFile', '', 1)
this.isSizeExceeded = false
@@ -412,21 +462,14 @@ export default class IscnUploadForm extends Vue {
reader.readAsDataURL(file)
// eslint-disable-next-line no-await-in-loop
- const fileBytes = (await fileToArrayBuffer(
- file,
- )) as unknown as ArrayBuffer
- if (fileBytes) {
- const [
+ const info = await this.getFileInfo(file)
+ if (info) {
+ const {
+ fileBytes,
fileSHA256,
imageType,
ipfsHash,
- // eslint-disable-next-line no-await-in-loop
- ] = await Promise.all([
- digestFileSHA256(fileBytes),
- readImageType(fileBytes),
- Hash.of(Buffer.from(fileBytes)),
- ])
-
+ } = info
fileRecord = {
...fileRecord,
fileName: file.name,
@@ -453,7 +496,7 @@ export default class IscnUploadForm extends Vue {
}
if (file.type === 'application/epub+zip') {
// eslint-disable-next-line no-await-in-loop
- await this.processEPub({ buffer: fileBytes, file })
+ await this.processEPub({ ipfsHash, buffer: fileBytes, file })
}
}
} else {
@@ -464,10 +507,41 @@ export default class IscnUploadForm extends Vue {
}
}
- async processEPub({ buffer, file }: { buffer: ArrayBuffer; file: File }) {
+ async processEPub({ ipfsHash, buffer, file }: { ipfsHash: string, buffer: ArrayBuffer; file: File }) {
try {
const book = ePub(buffer)
await book.ready
+ if (this.mode === MODE.EDIT) {
+ const modifiedEpub = await injectISCNQRCodePage(buffer, book, this.iscnId || '')
+
+ // eslint-disable-next-line no-await-in-loop
+ const info = await this.getFileInfo(modifiedEpub)
+ if (info) {
+ const {
+ fileSHA256: modifiedEpubSHA256,
+ ipfsHash: modifiedEpubIpfsHash,
+ } = info
+
+ const modifiedEpubRecord: any = {
+ fileName: file.name,
+ fileSize: modifiedEpub.size,
+ fileType: modifiedEpub.type,
+ fileBlob: modifiedEpub,
+ ipfsHash: modifiedEpubIpfsHash,
+ fileSHA256: modifiedEpubSHA256,
+ isFileImage: false,
+ }
+
+ const epubReader = new FileReader()
+ epubReader.onload = (e) => {
+ if (!e.target) return
+ modifiedEpubRecord.fileData = e.target.result as string
+ Vue.set(this.modifiedEpubMap, ipfsHash, modifiedEpubRecord)
+ }
+ epubReader.readAsDataURL(modifiedEpub)
+ }
+ }
+
const epubMetadata: any = {}
// Get metadata
@@ -510,39 +584,34 @@ export default class IscnUploadForm extends Vue {
type: 'image/jpeg',
},
)
- const fileBytes = (await fileToArrayBuffer(
- coverFile,
- )) as unknown as ArrayBuffer
- if (fileBytes) {
- const [
+
+ // eslint-disable-next-line no-await-in-loop
+ const coverInfo = await this.getFileInfo(coverFile)
+ if (coverInfo) {
+ const {
fileSHA256,
imageType,
- ipfsHash,
- // eslint-disable-next-line no-await-in-loop
- ] = await Promise.all([
- digestFileSHA256(fileBytes),
- readImageType(fileBytes),
- Hash.of(Buffer.from(fileBytes)),
- ])
+ ipfsHash: ipfsThumbnailHash,
+ } = coverInfo
- epubMetadata.thumbnailIpfsHash = ipfsHash
+ epubMetadata.thumbnailIpfsHash = ipfsThumbnailHash
- const fileRecord: any = {
+ const coverFileRecord: any = {
fileName: coverFile.name,
fileSize: coverFile.size,
fileType: coverFile.type,
fileBlob: coverFile,
- ipfsHash,
+ ipfsHash: ipfsThumbnailHash,
fileSHA256,
isFileImage: !!imageType,
}
- const reader = new FileReader()
- reader.onload = (e) => {
+ const coverReader = new FileReader()
+ coverReader.onload = (e) => {
if (!e.target) return
- fileRecord.fileData = e.target.result as string
- this.fileRecords.push(fileRecord)
+ coverFileRecord.fileData = e.target.result as string
+ this.fileRecords.push(coverFileRecord)
}
- reader.readAsDataURL(coverFile)
+ coverReader.readAsDataURL(coverFile)
}
}
}
@@ -589,6 +658,9 @@ export default class IscnUploadForm extends Vue {
handleDeleteFile(index: number) {
const deletedFile = this.fileRecords[index];
+ if (this.modifiedEpubMap[deletedFile.ipfsHash]) {
+ delete this.modifiedEpubMap[deletedFile.ipfsHash]
+ }
this.fileRecords.splice(index, 1);
const indexToDelete = this.epubMetadataList.findIndex(item => item.thumbnailIpfsHash === deletedFile.ipfsHashList);
@@ -616,7 +688,7 @@ export default class IscnUploadForm extends Vue {
async estimateArweaveFee(): Promise {
try {
const results = await Promise.all(
- this.fileRecords.map(async (record) => {
+ this.modifiedFileRecords.map(async (record) => {
const priceResult = await estimateBundlrFilePrice({
fileSize: record.fileBlob?.size || 0,
ipfsHash: record.ipfsHash,
@@ -755,7 +827,7 @@ export default class IscnUploadForm extends Vue {
this.uploadStatus = ''
return
}
- if (!this.fileRecords.some(file => file.fileBlob)) {
+ if (!this.modifiedFileRecords.some(file => file.fileBlob)) {
this.error = 'NO_FILE_TO_UPLOAD'
this.isOpenWarningSnackbar = true
this.uploadStatus = ''
@@ -765,7 +837,7 @@ export default class IscnUploadForm extends Vue {
try {
this.uploadStatus = 'uploading';
// eslint-disable-next-line no-restricted-syntax
- for (const record of this.fileRecords) {
+ for (const record of this.modifiedFileRecords) {
// eslint-disable-next-line no-await-in-loop
await this.submitToArweave(record);
}
@@ -781,17 +853,17 @@ export default class IscnUploadForm extends Vue {
}
const uploadArweaveIdList = Array.from(this.sentArweaveTransactionInfo.values()).map(entry => entry.arweaveId);
- this.fileRecords.forEach((record: any, index:number) => {
+ this.modifiedFileRecords.forEach((record: any, index:number) => {
if (this.sentArweaveTransactionInfo.has(record.ipfsHash)) {
const arweaveId = this.sentArweaveTransactionInfo.get(
record.ipfsHash,
)?.arweaveId
if (arweaveId) {
- this.fileRecords[index].arweaveId = arweaveId
+ this.modifiedFileRecords[index].arweaveId = arweaveId
}
}
})
- this.$emit('submit', { fileRecords: this.fileRecords, arweaveIds: uploadArweaveIdList, epubMetadata: this.epubMetadataList[0] })
+ this.$emit('submit', { fileRecords: this.modifiedFileRecords, arweaveIds: uploadArweaveIdList, epubMetadata: this.epubMetadataList[0] })
}
handleSignDialogClose() {
diff --git a/locales/en.json b/locales/en.json
index e20ed654..ea410166 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -374,6 +374,7 @@
"UploadForm.button.selectFile": "Select a file",
"UploadForm.button.skip": "Skip Upload",
"UploadForm.button": "Start Upload",
+ "UploadForm.label.insertISCNPage": "Add ISCN Page to Epub",
"UploadForm.guide.dropFile": "Drop your file here, or",
"UploadForm.guide.selectFile": "Select your file and publish to IPFS",
"UploadForm.title.registerISCN": "Register ISCN",
diff --git a/package.json b/package.json
index 778ed8bf..7c1f5bf2 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
"form-data": "^4.0.0",
"image-type": "^4.1.0",
"ipfs-only-hash": "^4.0.0",
+ "jszip": "^3.10.1",
"lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8",
"mime-types": "^2.1.34",
@@ -58,6 +59,7 @@
"puppeteer-extra": "^3.3.4",
"puppeteer-extra-plugin-anonymize-ua": "^2.4.4",
"puppeteer-extra-plugin-stealth": "^2.11.1",
+ "qrcode": "^1.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"uuid": "^8.3.2",
@@ -80,6 +82,7 @@
"@types/mime-types": "^2.1.1",
"@types/multer": "^1.4.7",
"@types/p5": "^1.3.0",
+ "@types/qrcode": "^1.5.5",
"@types/uuid": "^8.3.1",
"@vue/test-utils": "^1.2.1",
"@vue/vue2-jest": "^27.0.0",
diff --git a/pages/edit/_iscnId.vue b/pages/edit/_iscnId.vue
index 309790b7..ccb7c907 100644
--- a/pages/edit/_iscnId.vue
+++ b/pages/edit/_iscnId.vue
@@ -39,6 +39,7 @@
>
diff --git a/utils/epub/asset.ts b/utils/epub/asset.ts
new file mode 100644
index 00000000..ccfbb094
--- /dev/null
+++ b/utils/epub/asset.ts
@@ -0,0 +1,75 @@
+export const ISCN_LOCALE_CONFIG = {
+ 'en': {
+ TITLE_LABEL: 'Title',
+ AUTHOR_LABEL: 'Author',
+ RELEASE_DATE_LABEL: 'Release date',
+ DEPUB_DISCLAIMER: 'This book is published on decentralized networks',
+ },
+ 'zh': {
+ TITLE_LABEL: 'æžć',
+ AUTHOR_LABEL: 'äœè
',
+ RELEASE_DATE_LABEL: 'çŒèĄæ„æ',
+ DEPUB_DISCLAIMER: 'æ€æžæĄçšćæŁćŒćșç',
+ },
+}
+
+export const ISCN_CSS = `body {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ font-family: sans-serif;
+ text-align: center;
+}
+
+#iscn-page-body {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ flex-grow: 1;
+ padding: 0.5em 2vw 4em;
+}
+
+#iscn-page-footer {
+ border-top: 1px solid #ccc;
+ padding: 0.5em;
+}
+
+#iscn-qr-code {
+ margin-top: 0.5em;
+}
+
+#iscn-prefix {
+ font-size: 0.75em;
+ font-family: monospace;
+ word-break: break-all;
+}`
+
+export const ISCN_XHTML = `
+
+
+
+
+ ISCN on LikeCoin Chain
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+export const ISCN_LOGO_SVG = `
+`
diff --git a/utils/epub/iscn.ts b/utils/epub/iscn.ts
new file mode 100644
index 00000000..90d27924
--- /dev/null
+++ b/utils/epub/iscn.ts
@@ -0,0 +1,182 @@
+import QRCode from 'qrcode'
+import JSZip from 'jszip'
+import { Book } from 'epubjs'
+
+import { SITE_URL } from '~/constant'
+import { ISCN_CSS, ISCN_LOCALE_CONFIG, ISCN_LOGO_SVG, ISCN_XHTML } from './asset'
+import { extractIscnIdPrefix } from '../ui'
+
+type EPUB_LOCALE = 'zh' | 'en';
+
+const LIKE_GREEN = '#28646e'
+const QR_CODE_SIZE = 256
+const LOGO_SIZE = 64
+
+const ISCN_CSS_NAME = 'iscn.css'
+const ISCN_XHTML_NAME = 'iscn.xhtml'
+const ISCN_QR_CODE_PNG_NAME = 'iscn-qr-code.png'
+
+function loadImage(url: string): Promise {
+ return new Promise((resolve, reject) => {
+ const i = new Image();
+ i.onload = (() => resolve(i));
+ i.onerror = (e => reject(e));
+ i.src = url;
+ });
+}
+
+function getISCNURL(iscnPrefix: string) {
+ return `${SITE_URL}/view/${encodeURIComponent(iscnPrefix)}`
+}
+
+async function createQRCodeCanvas(iscnPrefix: string) {
+ const iscnURL = getISCNURL(iscnPrefix)
+ const initQRCode = await QRCode.toDataURL(iscnURL, {
+ color: {
+ light: LIKE_GREEN,
+ dark: '#fff',
+ },
+ errorCorrectionLevel: 'H',
+ margin: 2,
+ })
+ const canvas = document.createElement('canvas')
+ canvas.width = QR_CODE_SIZE
+ canvas.height = QR_CODE_SIZE
+ const ctx = canvas.getContext('2d')
+ if (!ctx) throw new Error('Cannot get 2d context')
+
+ const img = await loadImage(initQRCode)
+ ctx.drawImage(img, 0, 0, QR_CODE_SIZE, QR_CODE_SIZE)
+
+ // draw blank white circle
+ const circleCenter = canvas.width / 2
+ ctx.fillStyle = LIKE_GREEN
+ ctx.beginPath()
+ ctx.arc(circleCenter, circleCenter, LOGO_SIZE / 2 + 2, 0, 2 * Math.PI, false)
+ ctx.fill()
+
+ // draw logo
+ const logoBlob = new Blob([ISCN_LOGO_SVG], {type: 'image/svg+xml'});
+ const logoUrl = URL.createObjectURL(logoBlob);
+ const logoImage = await loadImage(logoUrl)
+ URL.revokeObjectURL(logoUrl)
+ const logoPosition = (canvas.width - LOGO_SIZE) / 2
+ ctx.drawImage(logoImage, logoPosition, logoPosition, LOGO_SIZE, LOGO_SIZE)
+
+ return canvas
+}
+
+function saveCanvas(canvas: HTMLCanvasElement): Promise {
+ return new Promise((resolve, reject) => {
+ canvas.toBlob((blob: Blob | null) => blob ? resolve(blob) : reject(new Error('Cannot save canvas to blob')))
+ })
+}
+
+function updateContentOPF(doc: Document, iscnPageHref: string, iscnQRCodeHref: string) {
+ if (doc.querySelector('#iscn-page')) return
+ const manifest = doc.querySelector('manifest')
+ manifest?.insertAdjacentHTML('beforeend', ` \n `)
+ manifest?.insertAdjacentHTML('beforeend', ` \n `)
+ manifest?.insertAdjacentHTML('beforeend', ` \n `)
+
+ const spine = doc.querySelector('spine')
+ spine?.insertAdjacentHTML('beforeend', ` \n `)
+}
+
+function readInfoMap(doc: Document, locale: EPUB_LOCALE = 'en') {
+ const titles = doc.querySelector("metadata title")
+ const title = titles?.textContent
+ const authors = doc.querySelector("metadata creator")
+ const author = authors?.textContent
+ const releaseDate = new Date().toLocaleDateString(locale, {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ })
+ const map = new Map()
+ map.set(ISCN_LOCALE_CONFIG[locale].TITLE_LABEL, title)
+ map.set(ISCN_LOCALE_CONFIG[locale].AUTHOR_LABEL, author)
+ map.set(ISCN_LOCALE_CONFIG[locale].RELEASE_DATE_LABEL, releaseDate)
+ return map
+}
+
+function addBookInfo(doc: Document, infoItemMap: Map) {
+ const div = doc.querySelector('body #iscn-page-book-info')
+ const itemsString = [...infoItemMap]
+ .filter(([
+ key,
+ value,
+ ]) => key && value)
+ .map(([
+ key,
+ value,
+ ]) => (
+ `${key}: ${value}
\n`
+ ))
+ .join('')
+ div?.insertAdjacentHTML('afterbegin', itemsString)
+}
+
+function setISCNLink(doc: Document, iscnPrefix: string) {
+ const a = doc.querySelector('body a#iscn-prefix')
+ const iscnURL = getISCNURL(iscnPrefix)
+ a?.setAttribute('href', iscnURL)
+ if (a) a.textContent = iscnPrefix
+}
+
+function addFooterDisclaimer(doc: Document, locale: EPUB_LOCALE = 'en') {
+ const footer = doc.querySelector('body #depub-disclaimer')
+ if (footer) {
+ footer.textContent = ISCN_LOCALE_CONFIG[locale].DEPUB_DISCLAIMER
+ }
+}
+
+export async function injectISCNQRCodePage(buffer: ArrayBuffer, book: Book, iscnId: string) {
+ const zipObject = new JSZip()
+ await zipObject.loadAsync(buffer)
+ const iscnPrefix = extractIscnIdPrefix(iscnId)
+ // create QR code
+ const canvas = await createQRCodeCanvas(iscnPrefix)
+ const oebpsPath: string = (book.container as any).directory
+ const blob = await saveCanvas(canvas)
+ if (!blob) throw new Error('Cannot save canvas to blob')
+ await zipObject.file(`${oebpsPath}/${ISCN_QR_CODE_PNG_NAME}`, blob)
+
+ // read and update content.opf
+ const path = (book.container as any).packagePath
+ const opfString = await zipObject.file((book.container as any).packagePath)?.async('string') || ''
+ const doc = new DOMParser().parseFromString(opfString, 'text/xml')
+ const metadataLocale = doc.querySelector("metadata language")?.textContent || 'en'
+ const locale = metadataLocale?.toLocaleLowerCase()?.includes('zh') ? 'zh' : 'en'
+ updateContentOPF(doc, ISCN_XHTML_NAME, ISCN_QR_CODE_PNG_NAME)
+ const infoMap = readInfoMap(doc, locale)
+ const updatedOpfString = new XMLSerializer().serializeToString(doc).toString()
+ await zipObject.file(path, updatedOpfString)
+
+ // add ISCN css
+ await zipObject.file(`${oebpsPath}/${ISCN_CSS_NAME}`, ISCN_CSS)
+
+ // add ISCN XHTML and update info
+ const iscnXHTMLPath = `${oebpsPath}/${ISCN_XHTML_NAME}`
+ const iscnXHTMLDoc = new DOMParser().parseFromString(ISCN_XHTML, 'text/xml')
+ addBookInfo(iscnXHTMLDoc, infoMap)
+ setISCNLink(iscnXHTMLDoc, iscnPrefix)
+ addFooterDisclaimer(iscnXHTMLDoc, locale)
+ const updatedISCNXHTMLString = new XMLSerializer().serializeToString(iscnXHTMLDoc).toString()
+
+ await zipObject.file(iscnXHTMLPath, updatedISCNXHTMLString)
+ await zipObject.file('mimetype', 'application/epub+zip', {
+ compression: 'STORE',
+ });
+ const epubBlob = await zipObject.generateAsync({
+ mimeType: 'application/epub+zip',
+ type: 'blob',
+ compression: 'DEFLATE',
+ compressionOptions: {
+ level: 9,
+ },
+ })
+ return epubBlob
+}
+
+export default injectISCNQRCodePage
diff --git a/utils/misc.ts b/utils/misc.ts
index 774dcb1e..fa0f8234 100644
--- a/utils/misc.ts
+++ b/utils/misc.ts
@@ -37,19 +37,19 @@ export function catchAxiosError(promise: AxiosPromise) {
});
}
+export function downloadFile (blob: Blob, filename: string) {
+ const file = window.URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = file
+ a.style.display = 'none'
+ a.download = filename
+ a.click()
+}
+
export function downloadJSON(data: Object, fileName: string) {
const jsonData = JSON.stringify(data, null, 2)
const jsonBlob = new Blob([jsonData], { type: 'application/json' })
- const jsonUrl = URL.createObjectURL(jsonBlob)
-
- const jsonLink = document.createElement('a')
- jsonLink.href = jsonUrl
- jsonLink.download = fileName
- jsonLink.style.display = 'none'
-
- document.body.appendChild(jsonLink)
- jsonLink.click()
- document.body.removeChild(jsonLink)
+ downloadFile(jsonBlob, fileName)
}
diff --git a/yarn.lock b/yarn.lock
index 09bbb57e..c99c5b64 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6313,6 +6313,13 @@
resolved "https://registry.yarnpkg.com/@types/pug/-/pug-2.0.6.tgz#f830323c88172e66826d0bde413498b61054b5a6"
integrity sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==
+"@types/qrcode@^1.5.5":
+ version "1.5.5"
+ resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.5.tgz#993ff7c6b584277eee7aac0a20861eab682f9dac"
+ integrity sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==
+ dependencies:
+ "@types/node" "*"
+
"@types/qs@*":
version "6.9.6"
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.6.tgz#df9c3c8b31a247ec315e6996566be3171df4b3b1"
@@ -14877,7 +14884,7 @@ jstransformer@1.0.0:
is-promise "^2.0.0"
promise "^7.0.1"
-jszip@^3.7.1:
+jszip@^3.10.1, jszip@^3.7.1:
version "3.10.1"
resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2"
integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==
@@ -18740,7 +18747,7 @@ qrcode@1.4.4:
pngjs "^3.3.0"
yargs "^13.2.4"
-qrcode@1.5.3:
+qrcode@1.5.3, qrcode@^1.5.3:
version "1.5.3"
resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.3.tgz#03afa80912c0dccf12bc93f615a535aad1066170"
integrity sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==