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 e417d2d9..ccb7c907 100644 --- a/pages/edit/_iscnId.vue +++ b/pages/edit/_iscnId.vue @@ -39,6 +39,7 @@ > @@ -281,10 +282,10 @@ export default class EditIscnPage extends Vue { description: this.description, keywords: this.contentMetadata.keywords, url: this.contentMetadata.url, - contentFingerprints: [ + contentFingerprints: Array.from(new Set([ ...this.contentFingerprints, ...this.customContentFingerprints, - ], + ])), stakeholders: this.iscnRecord?.stakeholders, type: this.contentMetadata['@type'], usageInfo: this.contentMetadata.usageInfo, 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==