From d600ec5e00f05d2ee9be0bae4e8c81c5e3fa2673 Mon Sep 17 00:00:00 2001 From: Abe Jellinek Date: Wed, 23 Oct 2024 12:00:04 -0400 Subject: [PATCH 1/3] Add support for importing KOReader & Calibre EPUB annotations --- package-lock.json | 27 +++++ package.json | 2 + src/common/reader.js | 16 +++ src/dom/common/lib/range.ts | 2 +- src/dom/epub/epub-view.ts | 97 +++++++++++++++- src/dom/epub/lib/calibre.ts | 71 ++++++++++++ src/dom/epub/lib/koreader.ts | 145 ++++++++++++++++++++++++ src/dom/epub/lib/sanitize-and-render.ts | 6 +- webpack.config.js | 6 + 9 files changed, 367 insertions(+), 5 deletions(-) create mode 100644 src/dom/epub/lib/calibre.ts create mode 100644 src/dom/epub/lib/koreader.ts diff --git a/package-lock.json b/package-lock.json index ff978b6f..da05356d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "classnames": "^2.3.1", "darkreader": "^4.9.83", "epubjs": "file:epubjs/epub.js", + "luaparse": "^0.3.1", "postcss-selector-parser": "^6.0.13", "prop-types": "^15.8.1", "queue": "^6.0.2", @@ -29,6 +30,7 @@ "@babel/preset-typescript": "^7.24.7", "@babel/runtime": "^7.18.9", "@svgr/webpack": "^8.1.0", + "@types/luaparse": "^0.2.12", "@types/react-dom": "^18.0.10", "@typescript-eslint/eslint-plugin": "^5.49.0", "@typescript-eslint/parser": "^5.49.0", @@ -3572,6 +3574,12 @@ "integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==", "dev": true }, + "node_modules/@types/luaparse": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@types/luaparse/-/luaparse-0.2.12.tgz", + "integrity": "sha512-liSE0OFeTsKejYrWVZHNA+WOPHj+kGjjZwE+xcojPM9SE2k8sdLDXZB4CObQ+uUkVtKKNOC8+m0wg1F7Nlmt8w==", + "dev": true + }, "node_modules/@types/markdown-it": { "version": "12.2.3", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", @@ -12593,6 +12601,14 @@ "yallist": "^3.0.2" } }, + "node_modules/luaparse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/luaparse/-/luaparse-0.3.1.tgz", + "integrity": "sha512-b21h2bFEbtGXmVqguHogbyrMAA0wOHyp9u/rx+w6Yc9pW1t9YjhGUsp87lYcp7pFRqSWN/PhFkrdIqKEUzRjjQ==", + "bin": { + "luaparse": "bin/luaparse" + } + }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -22758,6 +22774,12 @@ "integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==", "dev": true }, + "@types/luaparse": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@types/luaparse/-/luaparse-0.2.12.tgz", + "integrity": "sha512-liSE0OFeTsKejYrWVZHNA+WOPHj+kGjjZwE+xcojPM9SE2k8sdLDXZB4CObQ+uUkVtKKNOC8+m0wg1F7Nlmt8w==", + "dev": true + }, "@types/markdown-it": { "version": "12.2.3", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", @@ -29985,6 +30007,11 @@ "yallist": "^3.0.2" } }, + "luaparse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/luaparse/-/luaparse-0.3.1.tgz", + "integrity": "sha512-b21h2bFEbtGXmVqguHogbyrMAA0wOHyp9u/rx+w6Yc9pW1t9YjhGUsp87lYcp7pFRqSWN/PhFkrdIqKEUzRjjQ==" + }, "magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", diff --git a/package.json b/package.json index dba4466f..3547f95a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "classnames": "^2.3.1", "darkreader": "^4.9.83", "epubjs": "file:epubjs/epub.js", + "luaparse": "^0.3.1", "postcss-selector-parser": "^6.0.13", "prop-types": "^15.8.1", "queue": "^6.0.2", @@ -37,6 +38,7 @@ "@babel/preset-typescript": "^7.24.7", "@babel/runtime": "^7.18.9", "@svgr/webpack": "^8.1.0", + "@types/luaparse": "^0.2.12", "@types/react-dom": "^18.0.10", "@typescript-eslint/eslint-plugin": "^5.49.0", "@typescript-eslint/parser": "^5.49.0", diff --git a/src/common/reader.js b/src/common/reader.js index 1c77511c..1ec93059 100644 --- a/src/common/reader.js +++ b/src/common/reader.js @@ -1029,6 +1029,22 @@ class Reader { return this._annotationManager.mergeAnnotations(ids); } + /** + * @param {BufferSource} metadata + */ + importAnnotationsFromKOReaderMetadata(metadata) { + this._ensureType('epub'); + this._primaryView.importAnnotationsFromKOReaderMetadata(metadata); + } + + /** + * @param {string} metadata + */ + importAnnotationsFromCalibreMetadata(metadata) { + this._ensureType('epub'); + this._primaryView.importAnnotationsFromCalibreMetadata(metadata); + } + /** * Trigger copying inside the currently focused iframe or the main window */ diff --git a/src/dom/common/lib/range.ts b/src/dom/common/lib/range.ts index d6a18c25..171ccde9 100644 --- a/src/dom/common/lib/range.ts +++ b/src/dom/common/lib/range.ts @@ -15,7 +15,7 @@ export class PersistentRange { endOffset: number; - constructor(range: AbstractRange) { + constructor(range: Omit) { this.startContainer = range.startContainer; this.startOffset = range.startOffset; this.endContainer = range.endContainer; diff --git a/src/dom/epub/epub-view.ts b/src/dom/epub/epub-view.ts index b4db4a32..9c26c5c2 100644 --- a/src/dom/epub/epub-view.ts +++ b/src/dom/epub/epub-view.ts @@ -56,6 +56,9 @@ import { ScrolledFlow } from "./flow"; import { DEFAULT_EPUB_APPEARANCE, RTL_SCRIPTS } from "./defines"; +import { parseAnnotationsFromKOReaderMetadata, koReaderAnnotationToRange } from "./lib/koreader"; +import { ANNOTATION_COLORS } from "../../common/defines"; +import { calibreAnnotationToRange, parseAnnotationsFromCalibreMetadata } from "./lib/calibre"; class EPUBView extends DOMView { protected _find: EPUBFindProcessor | null = null; @@ -351,7 +354,7 @@ class EPUBView extends DOMView { return { type: 'FragmentSelector', conformsTo: FragmentSelectorConformsTo.EPUB3, - value: cfi.toString() + value: cfi.toString(true) }; } @@ -472,6 +475,98 @@ class EPUBView extends DOMView { }; } + private _upsertAnnotation(annotation: NewAnnotation) { + let existingAnnotation = this._annotations.find( + existingAnnotation => existingAnnotation.text === annotation!.text + && existingAnnotation.sortIndex === annotation!.sortIndex + ); + if (existingAnnotation) { + this._options.onUpdateAnnotations([{ + ...existingAnnotation, + comment: annotation.comment, + }]); + } + else { + this._options.onAddAnnotation(annotation); + } + } + + importAnnotationsFromKOReaderMetadata(metadata: BufferSource) { + for (let koReaderAnnotation of parseAnnotationsFromKOReaderMetadata(metadata)) { + let range = koReaderAnnotationToRange(koReaderAnnotation, this._sectionRenderers); + if (!range) { + console.warn('Unable to resolve annotation', koReaderAnnotation); + continue; + } + + let annotation = this._getAnnotationFromRange( + range, + 'highlight', + ANNOTATION_COLORS[0][1] // Yellow + ); + if (!annotation) { + console.warn('Unable to resolve range', koReaderAnnotation); + continue; + } + annotation.comment = koReaderAnnotation.note; + + this._upsertAnnotation(annotation); + } + } + + importAnnotationsFromCalibreMetadata(metadata: string) { + for (let calibreAnnotation of parseAnnotationsFromCalibreMetadata(metadata)) { + let range = calibreAnnotationToRange(calibreAnnotation, this._sectionRenderers); + if (!range) { + console.warn('Unable to resolve annotation', calibreAnnotation); + continue; + } + + let type: 'highlight' | 'underline' = 'highlight'; + let color = ANNOTATION_COLORS[0][1]; // Default to yellow + switch (calibreAnnotation.style?.kind) { + case 'color': + switch (calibreAnnotation.style.which) { + case 'green': + color = ANNOTATION_COLORS[2][1]; + break; + case 'blue': + color = ANNOTATION_COLORS[3][1]; + break; + case 'purple': + color = ANNOTATION_COLORS[4][1]; + break; + case 'pink': + color = ANNOTATION_COLORS[5][1]; + break; + case 'yellow': + default: + break; + } + break; + case 'decoration': + switch (calibreAnnotation.style.which) { + case 'strikeout': + color = ANNOTATION_COLORS[1][1]; // Red highlight as a stand-in + break; + case 'wavy': + type = 'underline'; + break; + } + break; + } + + let annotation = this._getAnnotationFromRange(range, type, color); + if (!annotation) { + console.warn('Unable to resolve range', calibreAnnotation); + continue; + } + annotation.comment = calibreAnnotation.notes || ''; + + this._upsertAnnotation(annotation); + } + } + // *** // Event handlers // *** diff --git a/src/dom/epub/lib/calibre.ts b/src/dom/epub/lib/calibre.ts new file mode 100644 index 00000000..2bf391e3 --- /dev/null +++ b/src/dom/epub/lib/calibre.ts @@ -0,0 +1,71 @@ +import { EpubCFI } from "epubjs"; +import SectionRenderer from "../section-renderer"; +import { lengthenCFI } from "../cfi"; + +export function calibreAnnotationToRange(annotation: CalibreAnnotation, sectionRenderers: SectionRenderer[]): Range | null { + let sectionRenderer = sectionRenderers[annotation.spine_index]; + if (!sectionRenderer) { + return null; + } + + // Calibre CFIs are basically valid EPUB CFIs, but they're missing the step + // indirection part, and they have an extra leading /2 (first element child) + // selector because they're relative to the root of the document instead of + // its root element. + // Some simple cleanup should make them parse correctly. + let startCFI = new EpubCFI(lengthenCFI( + sectionRenderer.section.cfiBase + '!' + + annotation.start_cfi.replace(/^\/2\//, '/'))); + let endCFI = new EpubCFI(lengthenCFI( + sectionRenderer.section.cfiBase + '!' + + annotation.end_cfi.replace(/^\/2\//, '/'))); + try { + let startRange = startCFI.toRange(sectionRenderer.container.ownerDocument, undefined, sectionRenderer.container); + let endRange = endCFI.toRange(sectionRenderer.container.ownerDocument, undefined, sectionRenderer.container); + startRange.setEnd(endRange.endContainer, endRange.endOffset); + return startRange; + } + catch (e) { + console.error(e); + return null; + } +} + +export function parseAnnotationsFromCalibreMetadata(metadata: string) { + let doc = new DOMParser().parseFromString(metadata, 'text/xml'); + let annotationMetas = doc.querySelectorAll('meta[name="calibre:annotation"][content]'); + let calibreAnnotations: CalibreAnnotation[] = []; + for (let annotationMeta of annotationMetas) { + let annotation; + try { + annotation = JSON.parse(annotationMeta.getAttribute('content')!); + } + catch (e) { + console.error(e); + continue; + } + if (annotation.format !== 'EPUB' + || 'removed' in annotation.annotation && annotation.annotation.removed + || annotation.annotation.type !== 'highlight') { + continue; + } + calibreAnnotations.push(annotation.annotation); + } + return calibreAnnotations; +} + +export type CalibreAnnotation = { + // There's more in the metadata, but these are all we need + type: 'highlight'; + spine_index: number; + start_cfi: string; + end_cfi: string; + notes?: string; + style?: { + kind: 'color'; + which: 'yellow' | 'green' | 'blue' | 'pink' | 'purple' | string; + } | { + kind: 'decoration'; + which: 'wavy' | 'strikeout' | string; + }; +}; diff --git a/src/dom/epub/lib/koreader.ts b/src/dom/epub/lib/koreader.ts new file mode 100644 index 00000000..6bb40455 --- /dev/null +++ b/src/dom/epub/lib/koreader.ts @@ -0,0 +1,145 @@ +import { SANITIZER_REPLACE_TAGS } from "./sanitize-and-render"; +import EPUBView from "../epub-view"; +import { Expression, parse as parseLua, ReturnStatement, StringLiteral } from "luaparse"; +import { TableConstructorExpression } from "luaparse/lib/ast"; +import SectionRenderer from "../section-renderer"; + +const SANITIZER_REPLACE_TAGS_RE = new RegExp( + '((?:^|\\s*/)\\s*)(' + Array.from(SANITIZER_REPLACE_TAGS).join('|') + ')', + 'g'); + +export function parseKOReaderPosition(position: string): KOReaderPosition { + const KOREADER_POSITION_RE = /^\/body\/DocFragment\[(\d+)]\/(.+)\.(\d+)$/; + + let matches = position.match(KOREADER_POSITION_RE); + if (!matches) { + throw new Error('Unable to parse KOReader position: ' + position); + } + + let fragmentIndex = parseInt(matches[1]); + let xpath = matches[2].replace( + SANITIZER_REPLACE_TAGS_RE, + (_, prefix, tag) => prefix + 'replaced-' + tag); + let charIndex = parseInt(matches[3]); + + return { fragmentIndex, xpath, charIndex }; +} + +export function pointFromKOReaderPosition( + position: KOReaderPosition | string, + sectionRenderers: SectionRenderer[] +): { node: Node, offset: number } | null { + if (typeof position === 'string') { + position = parseKOReaderPosition(position); + } + + let sectionRenderer = sectionRenderers[position.fragmentIndex - 1]; + if (!sectionRenderer) { + return null; + } + + let sectionRoot = sectionRenderer.body.parentElement!; + let nodeResult = sectionRoot.ownerDocument.evaluate( + position.xpath, + sectionRoot, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + ); + if (!nodeResult.singleNodeValue) { + return null; + } + + return { + node: nodeResult.singleNodeValue, + offset: position.charIndex, + }; +} + +export function koReaderAnnotationToRange(annotation: KOReaderAnnotation, sectionRenderers: SectionRenderer[]): Range | null { + let startPoint = pointFromKOReaderPosition(annotation.pos0, sectionRenderers); + let endPoint = pointFromKOReaderPosition(annotation.pos1, sectionRenderers); + if (!startPoint || !endPoint) { + return null; + } + if (EPUBView.getContainingSectionIndex(startPoint.node) !== EPUBView.getContainingSectionIndex(endPoint.node)) { + // Shouldn't actually happen + throw new Error('Start and end points are in different sections'); + } + let range = startPoint.node.ownerDocument!.createRange(); + range.setStart(startPoint.node, startPoint.offset); + range.setEnd(endPoint.node, endPoint.offset); + return range; +} + +export function parseAnnotationsFromKOReaderMetadata(metadata: BufferSource): KOReaderAnnotation[] { + function findField(table: TableConstructorExpression, fieldName: string): Expression | null { + return table.fields.find( + field => field.type !== 'TableValue' + && (field.key.type === 'StringLiteral' && field.key.value === fieldName + || field.key.type === 'Identifier' && field.key.name === fieldName) + )?.value ?? null; + } + + let ast = parseLua(new TextDecoder('x-user-defined').decode(metadata), { + comments: false, + scope: false, + locations: false, + ranges: false, + encodingMode: 'x-user-defined', + }); + let returnStatement = ast.body.find(s => s.type === 'ReturnStatement') as ReturnStatement | null; + if (!returnStatement) { + throw new Error('Invalid KOReader metadata: no top-level return statement'); + } + let metadataTable = returnStatement.arguments[0]; + if (metadataTable.type !== 'TableConstructorExpression') { + throw new Error('Invalid KOReader metadata: does not return table'); + } + let annotationsTable = findField(metadataTable, 'annotations'); + if (annotationsTable?.type !== 'TableConstructorExpression') { + throw new Error('Invalid KOReader metadata: "annotations" is not a table'); + } + + let annotations: KOReaderAnnotation[] = []; + for (let annotationTableField of annotationsTable.fields) { + let annotationTable = annotationTableField.value; + if (annotationTable.type !== 'TableConstructorExpression') { + throw new Error('Invalid KOReader metadata: "annotations" entry is not a table'); + } + let annotationFields = { + note: findField(annotationTable, 'note'), + pos0: findField(annotationTable, 'pos0'), + pos1: findField(annotationTable, 'pos1'), + text: findField(annotationTable, 'text'), + }; + for (let [key, value] of Object.entries(annotationFields)) { + if (['pos0', 'pos1', 'text'].includes(key) && !value) { + throw new Error(`Invalid KOReader metadata: annotation is missing required field "${key}"`); + } + if (value && value.type !== 'StringLiteral') { + throw new Error(`Invalid KOReader metadata: annotation field "${key}" is not a string`); + } + } + annotations.push({ + note: (annotationFields.note as StringLiteral | null)?.value, + pos0: parseKOReaderPosition((annotationFields.pos0 as StringLiteral).value), + pos1: parseKOReaderPosition((annotationFields.pos1 as StringLiteral).value), + text: (annotationFields.text as StringLiteral).value, + }); + } + return annotations; +} + +export type KOReaderAnnotation = { + // There's more in the metadata, but these are all we need + note?: string; + pos0: KOReaderPosition; + pos1: KOReaderPosition; + text: string; +}; + +export type KOReaderPosition = { + fragmentIndex: number; + xpath: string; + charIndex: number; +}; diff --git a/src/dom/epub/lib/sanitize-and-render.ts b/src/dom/epub/lib/sanitize-and-render.ts index d1db3e55..4be263d0 100644 --- a/src/dom/epub/lib/sanitize-and-render.ts +++ b/src/dom/epub/lib/sanitize-and-render.ts @@ -1,6 +1,6 @@ import parser from "postcss-selector-parser"; -const REPLACE_TAGS = new Set(['html', 'head', 'body', 'base', 'meta']); +export const SANITIZER_REPLACE_TAGS = new Set(['html', 'head', 'body', 'base', 'meta']); export async function sanitizeAndRender(xhtml: string, options: { container: Element, @@ -21,7 +21,7 @@ export async function sanitizeAndRender(xhtml: string, options: { let elem: Element | null = null; // eslint-disable-next-line no-unmodified-loop-condition while ((elem = walker.nextNode() as Element)) { - if (REPLACE_TAGS.has(elem.tagName)) { + if (SANITIZER_REPLACE_TAGS.has(elem.tagName)) { let newElem = doc.createElement('replaced-' + elem.tagName); for (let attr of elem.getAttributeNames()) { newElem.setAttribute(attr, elem.getAttribute(attr)!); @@ -202,7 +202,7 @@ export class StyleScoper { parser.selector({ ...selector, nodes: selector.nodes.map((node) => { - if (node.type === 'tag' && REPLACE_TAGS.has(node.value.toLowerCase())) { + if (node.type === 'tag' && SANITIZER_REPLACE_TAGS.has(node.value.toLowerCase())) { return parser.tag({ ...node, value: 'replaced-' + node.value diff --git a/webpack.config.js b/webpack.config.js index c7957ff3..870377c3 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -124,6 +124,12 @@ function generateReaderConfig(build) { 'prop-types': 'PropTypes' }; } + else if (build === 'web') { + config.externals = { + // No support for importing EPUB annotations on the web, so no need for luaparse there + luaparse: 'luaparse', + }; + } else if (build === 'dev') { config.plugins.push( new CopyWebpackPlugin({ From cfb5058f3975a387f777175b50eb791ca32b734c Mon Sep 17 00:00:00 2001 From: Abe Jellinek Date: Wed, 23 Oct 2024 16:31:15 -0400 Subject: [PATCH 2/3] Support Calibre embedded bookmarks, be tolerant of Calibre CFIs --- src/dom/epub/lib/calibre.ts | 46 +++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/src/dom/epub/lib/calibre.ts b/src/dom/epub/lib/calibre.ts index 2bf391e3..dc750b3e 100644 --- a/src/dom/epub/lib/calibre.ts +++ b/src/dom/epub/lib/calibre.ts @@ -20,8 +20,8 @@ export function calibreAnnotationToRange(annotation: CalibreAnnotation, sectionR sectionRenderer.section.cfiBase + '!' + annotation.end_cfi.replace(/^\/2\//, '/'))); try { - let startRange = startCFI.toRange(sectionRenderer.container.ownerDocument, undefined, sectionRenderer.container); - let endRange = endCFI.toRange(sectionRenderer.container.ownerDocument, undefined, sectionRenderer.container); + let startRange = startCFI.toRange(sectionRenderer.container.ownerDocument, undefined, sectionRenderer.container, { calibreCompat: true }); + let endRange = endCFI.toRange(sectionRenderer.container.ownerDocument, undefined, sectionRenderer.container, { calibreCompat: true }); startRange.setEnd(endRange.endContainer, endRange.endOffset); return startRange; } @@ -32,24 +32,36 @@ export function calibreAnnotationToRange(annotation: CalibreAnnotation, sectionR } export function parseAnnotationsFromCalibreMetadata(metadata: string) { - let doc = new DOMParser().parseFromString(metadata, 'text/xml'); - let annotationMetas = doc.querySelectorAll('meta[name="calibre:annotation"][content]'); let calibreAnnotations: CalibreAnnotation[] = []; - for (let annotationMeta of annotationMetas) { - let annotation; - try { - annotation = JSON.parse(annotationMeta.getAttribute('content')!); + if (metadata.startsWith('encoding=json+base64:\n')) { + let bookmarks = JSON.parse(atob(metadata.substring('encoding=json+base64:\n'.length))) as any[]; + for (let bookmark of bookmarks) { + if ('removed' in bookmark && bookmark.removed + || bookmark.type !== 'highlight') { + continue; + } + calibreAnnotations.push(bookmark); } - catch (e) { - console.error(e); - continue; - } - if (annotation.format !== 'EPUB' - || 'removed' in annotation.annotation && annotation.annotation.removed - || annotation.annotation.type !== 'highlight') { - continue; + } + else { + let doc = new DOMParser().parseFromString(metadata, 'text/xml'); + let annotationMetas = doc.querySelectorAll('meta[name="calibre:annotation"][content]'); + for (let annotationMeta of annotationMetas) { + let annotation; + try { + annotation = JSON.parse(annotationMeta.getAttribute('content')!); + } + catch (e) { + console.error(e); + continue; + } + if (annotation.format !== 'EPUB' + || 'removed' in annotation.annotation && annotation.annotation.removed + || annotation.annotation.type !== 'highlight') { + continue; + } + calibreAnnotations.push(annotation.annotation); } - calibreAnnotations.push(annotation.annotation); } return calibreAnnotations; } From 88f2b5be54a59391d7708e35a6c9c662382db518 Mon Sep 17 00:00:00 2001 From: Abe Jellinek Date: Thu, 24 Oct 2024 12:52:18 -0400 Subject: [PATCH 3/3] Add stats functions --- src/common/reader.js | 18 ++++++++++++++++++ src/dom/epub/epub-view.ts | 32 ++++++++++++++++++++++++++++++++ src/dom/epub/lib/calibre.ts | 1 + src/dom/epub/lib/koreader.ts | 5 ++++- 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/common/reader.js b/src/common/reader.js index 1ec93059..3642a7c7 100644 --- a/src/common/reader.js +++ b/src/common/reader.js @@ -1029,6 +1029,15 @@ class Reader { return this._annotationManager.mergeAnnotations(ids); } + /** + * @param {BufferSource} metadata + * @returns {{ count: number, lastModified?: Date }} + */ + getKOReaderAnnotationStats(metadata) { + this._ensureType('epub'); + return this._primaryView.getKOReaderAnnotationStats(metadata); + } + /** * @param {BufferSource} metadata */ @@ -1037,6 +1046,15 @@ class Reader { this._primaryView.importAnnotationsFromKOReaderMetadata(metadata); } + /** + * @param {string} metadata + * @returns {{ count: number, lastModified?: Date }} + */ + getCalibreAnnotationStats(metadata) { + this._ensureType('epub'); + return this._primaryView.getCalibreAnnotationStats(metadata); + } + /** * @param {string} metadata */ diff --git a/src/dom/epub/epub-view.ts b/src/dom/epub/epub-view.ts index 9c26c5c2..1a7f3c0b 100644 --- a/src/dom/epub/epub-view.ts +++ b/src/dom/epub/epub-view.ts @@ -491,6 +491,22 @@ class EPUBView extends DOMView { } } + getKOReaderAnnotationStats(metadata: BufferSource): { count: number, lastModified?: Date } { + try { + let annotations = parseAnnotationsFromKOReaderMetadata(metadata); + if (annotations.length) { + return { + count: annotations.length, + lastModified: annotations.map(a => new Date(a.datetime)).reduce( + (max, cur) => (cur > max ? cur : max) + ), + }; + } + } + catch (e) {} + return { count: 0 }; + } + importAnnotationsFromKOReaderMetadata(metadata: BufferSource) { for (let koReaderAnnotation of parseAnnotationsFromKOReaderMetadata(metadata)) { let range = koReaderAnnotationToRange(koReaderAnnotation, this._sectionRenderers); @@ -514,6 +530,22 @@ class EPUBView extends DOMView { } } + getCalibreAnnotationStats(metadata: string): { count: number, lastModified?: Date } { + try { + let annotations = parseAnnotationsFromCalibreMetadata(metadata); + if (annotations.length) { + return { + count: annotations.length, + lastModified: annotations.map(a => new Date(a.timestamp)).reduce( + (max, cur) => (cur > max ? cur : max) + ), + }; + } + } + catch (e) {} + return { count: 0 }; + } + importAnnotationsFromCalibreMetadata(metadata: string) { for (let calibreAnnotation of parseAnnotationsFromCalibreMetadata(metadata)) { let range = calibreAnnotationToRange(calibreAnnotation, this._sectionRenderers); diff --git a/src/dom/epub/lib/calibre.ts b/src/dom/epub/lib/calibre.ts index dc750b3e..51d02530 100644 --- a/src/dom/epub/lib/calibre.ts +++ b/src/dom/epub/lib/calibre.ts @@ -80,4 +80,5 @@ export type CalibreAnnotation = { kind: 'decoration'; which: 'wavy' | 'strikeout' | string; }; + timestamp: string; }; diff --git a/src/dom/epub/lib/koreader.ts b/src/dom/epub/lib/koreader.ts index 6bb40455..14328b95 100644 --- a/src/dom/epub/lib/koreader.ts +++ b/src/dom/epub/lib/koreader.ts @@ -111,9 +111,10 @@ export function parseAnnotationsFromKOReaderMetadata(metadata: BufferSource): KO pos0: findField(annotationTable, 'pos0'), pos1: findField(annotationTable, 'pos1'), text: findField(annotationTable, 'text'), + datetime: findField(annotationTable, 'datetime'), }; for (let [key, value] of Object.entries(annotationFields)) { - if (['pos0', 'pos1', 'text'].includes(key) && !value) { + if (['pos0', 'pos1', 'text', 'datetime'].includes(key) && !value) { throw new Error(`Invalid KOReader metadata: annotation is missing required field "${key}"`); } if (value && value.type !== 'StringLiteral') { @@ -125,6 +126,7 @@ export function parseAnnotationsFromKOReaderMetadata(metadata: BufferSource): KO pos0: parseKOReaderPosition((annotationFields.pos0 as StringLiteral).value), pos1: parseKOReaderPosition((annotationFields.pos1 as StringLiteral).value), text: (annotationFields.text as StringLiteral).value, + datetime: (annotationFields.datetime as StringLiteral).value, }); } return annotations; @@ -136,6 +138,7 @@ export type KOReaderAnnotation = { pos0: KOReaderPosition; pos1: KOReaderPosition; text: string; + datetime: string; }; export type KOReaderPosition = {