diff --git a/src/common/models/sync.ts b/src/common/models/sync.ts index 02b558333..ffbcc0994 100644 --- a/src/common/models/sync.ts +++ b/src/common/models/sync.ts @@ -28,9 +28,16 @@ export interface WithDestination { destination: WindowReaderDestination; } +export interface AcrossRenderer { + sendActionAcrossRenderer: boolean; +} + // tslint:disable-next-line: max-line-length export interface ActionWithSender extends Action, WithSender { } export interface ActionWithDestination extends Action, WithDestination { } + +export interface ActionAcrossRenderer extends Action, AcrossRenderer { +} diff --git a/src/common/readium/annotation/annotationModel.type.ts b/src/common/readium/annotation/annotationModel.type.ts index e881e1769..b35837343 100644 --- a/src/common/readium/annotation/annotationModel.type.ts +++ b/src/common/readium/annotation/annotationModel.type.ts @@ -8,7 +8,18 @@ import Ajv from "ajv"; import addFormats from "ajv-formats"; -export interface IReadiumAnnotationModel { +export interface IReadiumAnnotationSet { + "@context": "http://www.w3.org/ns/anno.jsonld"; + id: string; + type: "AnnotationSet"; + generator?: Generator; + generated?: string; + title?: string; + about: About; + items: IReadiumAnnotation[]; +} + +export interface IReadiumAnnotation { "@context": "http://www.w3.org/ns/anno.jsonld"; id: string; created: string; @@ -39,15 +50,38 @@ export interface IReadiumAnnotationModel { page?: string; }; selector: Array<( - ITextQuoteSelector - | IProgressionSelector - | IDomRangeSelector - | IFragmentSelector + ISelector + // ITextQuoteSelector + // | ITextPositionSelector + // | IFragmentSelector )>; }; } -export interface ITextQuoteSelector { +export interface ISelector { + type: string; + refinedBy?: T; +} + +/** +{ + "type": "TextPositionSelector", + "start": 50, + "end": 55 +} +*/ +export interface ITextPositionSelector extends ISelector { + type: "TextPositionSelector", + start: number, + end: number, +} +export function isTextPositionSelector(a: any): a is ITextPositionSelector { + return typeof a === "object" && a.type === "TextPositionSelector" + && typeof a.start === "number" + && typeof a.end === "number"; +} + +export interface ITextQuoteSelector extends ISelector { type: "TextQuoteSelector"; exact: string; prefix: string; @@ -60,7 +94,7 @@ export function isTextQuoteSelector(a: any): a is ITextQuoteSelector { && typeof a.suffix === "string"; } -export interface IProgressionSelector { +export interface IProgressionSelector extends ISelector { type: "ProgressionSelector"; value: number; } @@ -69,27 +103,39 @@ export function isProgressionSelector(a: any): a is IProgressionSelector { && typeof a.value === "number"; } -export interface IDomRangeSelector { - type: "DomRangeSelector"; - startContainerElementCssSelector: string; - startContainerChildTextNodeIndex: number; - startOffset: number; - endContainerElementCssSelector: string; - endContainerChildTextNodeIndex: number; - endOffset: number; +export interface ICssSelector extends ISelector { + type: "CssSelector"; + value: string; } -export function isDomRangeSelector(a: any): a is IDomRangeSelector { - return typeof a === "object" - && a.type === "DomRangeSelector" - && typeof a.startContainerElementCssSelector === "string" - && typeof a.startContainerChildTextNodeIndex === "number" - && typeof a.startOffset === "number" - && typeof a.endContainerElementCssSelector === "string" - && typeof a.endContainerChildTextNodeIndex === "number" - && typeof a.endOffset === "number"; +export function isCssSelector(a: any): a is ICssSelector { + return typeof a === "object" && a.type === "CssSelector" + && typeof a.value === "string"; } -export interface IFragmentSelector { +// not used anymore +// internal DOMRange selector not shared across annotation selector +// We prefer EPUB-CFI nowadays when official library will be choosen +// export interface IDomRangeSelector { +// type: "DomRangeSelector"; +// startContainerElementCssSelector: string; +// startContainerChildTextNodeIndex: number; +// startOffset: number; +// endContainerElementCssSelector: string; +// endContainerChildTextNodeIndex: number; +// endOffset: number; +// } +// export function isDomRangeSelector(a: any): a is IDomRangeSelector { +// return typeof a === "object" +// && a.type === "DomRangeSelector" +// && typeof a.startContainerElementCssSelector === "string" +// && typeof a.startContainerChildTextNodeIndex === "number" +// && typeof a.startOffset === "number" +// && typeof a.endContainerElementCssSelector === "string" +// && typeof a.endContainerChildTextNodeIndex === "number" +// && typeof a.endOffset === "number"; +// } + +export interface IFragmentSelector extends ISelector { type: "FragmentSelector"; conformsTo: string; value: string; @@ -101,6 +147,14 @@ export function isFragmentSelector(a: any): a is IFragmentSelector { && typeof a.value === "string"; } +export interface ICFIFragmentSelector extends IFragmentSelector { + conformsTo: "http://www.idpf.org/epub/linking/cfi/epub-cfi.html", +} +export function isCFIFragmentSelector(a: any): a is ICFIFragmentSelector { + return isFragmentSelector(a) + && a.conformsTo === "http://www.idpf.org/epub/linking/cfi/epub-cfi.html"; +} + interface Generator { id: string; type: string; @@ -117,20 +171,9 @@ interface About { "dc:date"?: string; } -export interface IReadiumAnnotationModelSet { - "@context": "http://www.w3.org/ns/anno.jsonld"; - id: string; - type: "AnnotationSet"; - generator?: Generator; - generated?: string; - title?: string; - about: About; - items: IReadiumAnnotationModel[]; -} - -export const readiumAnnotationModelSetJSONSchema3 = { +export const readiumAnnotationSetSchema = { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "IReadiumAnnotationModelSet", + "title": "IReadiumAnnotationSet", "type": "object", "properties": { "@context": { @@ -214,15 +257,15 @@ export const readiumAnnotationModelSetJSONSchema3 = { "items": { "type": "array", "items": { - "$ref": "#/definitions/IReadiumAnnotationModel", + "$ref": "#/definitions/IReadiumAnnotation", }, }, }, "required": ["@context", "id", "type", "about", "items"], "definitions": { - "IReadiumAnnotationModel": { + "IReadiumAnnotation": { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "IReadiumAnnotationModelSet", + "title": "IReadiumAnnotationSet", "type": "object", "properties": { "@context": { @@ -340,17 +383,14 @@ export const readiumAnnotationModelSetJSONSchema3 = { "items": { "oneOf": [ { - "$ref": "#/definitions/ITextQuoteSelector", - }, - { - "$ref": "#/definitions/IProgressionSelector", - }, - { - "$ref": "#/definitions/IDomRangeSelector", - }, - { - "$ref": "#/definitions/IFragmentSelector", + "$ref": "#/definitions/Selector", }, + // { + // "$ref": "#/definitions/ITextPositionSelector", + // }, + // { + // "$ref": "#/definitions/IFragmentSelector", + // }, ], }, }, @@ -360,97 +400,74 @@ export const readiumAnnotationModelSetJSONSchema3 = { }, "required": ["@context", "id", "created", "type", "target"], }, - "ITextQuoteSelector": { + "Selector": { "type": "object", "properties": { "type": { - "const": "TextQuoteSelector", - }, - "exact": { - "type": "string", - }, - "prefix": { - "type": "string", - }, - "suffix": { - "type": "string", - }, - }, - "required": ["type", "exact", "prefix", "suffix"], - }, - "IProgressionSelector": { - "type": "object", - "properties": { - "type": { - "const": "ProgressionSelector", - }, - "value": { - "type": "number", - }, - }, - "required": ["type", "value"], - }, - "IDomRangeSelector": { - "type": "object", - "properties": { - "type": { - "const": "DomRangeSelector", - }, - "startContainerElementCssSelector": { - "type": "string", - }, - "startContainerChildTextNodeIndex": { - "type": "number", - }, - "startOffset": { - "type": "number", - }, - "endContainerElementCssSelector": { - "type": "string", - }, - "endContainerChildTextNodeIndex": { - "type": "number", - }, - "endOffset": { - "type": "number", - }, - }, - "required": [ - "type", - "startContainerElementCssSelector", - "startContainerChildTextNodeIndex", - "startOffset", - "endContainerElementCssSelector", - "endContainerChildTextNodeIndex", - "endOffset", - ], - }, - "IFragmentSelector": { - "type": "object", - "properties": { - "type": { - "const": "FragmentSelector", - }, - "conformsTo": { - "type": "string", - }, - "value": { "type": "string", }, }, - "required": ["type", "conformsTo", "value"], + "required": ["type"], }, + // "ITextQuoteSelector": { + // "type": "object", + // "properties": { + // "type": { + // "const": "TextQuoteSelector", + // }, + // "exact": { + // "type": "string", + // }, + // "prefix": { + // "type": "string", + // }, + // "suffix": { + // "type": "string", + // }, + // }, + // "required": ["type", "exact", "prefix", "suffix"], + // }, + // "ITextPositionSelector": { + // "type": "object", + // "properties": { + // "type": { + // "const": "TextPositionSelector", + // }, + // "start": { + // "type": "number", + // }, + // "end": { + // "type": "number", + // }, + // }, + // "required": ["type", "start", "end"], + // }, + // "IFragmentSelector": { + // "type": "object", + // "properties": { + // "type": { + // "const": "FragmentSelector", + // }, + // "conformsTo": { + // "type": "string", + // }, + // "value": { + // "type": "string", + // }, + // }, + // "required": ["type", "conformsTo", "value"], + // }, }, }; export let __READIUM_ANNOTATION_AJV_ERRORS = ""; -export function isIReadiumAnnotationModelSet(data: any): data is IReadiumAnnotationModelSet { +export function isIReadiumAnnotationSet(data: any): data is IReadiumAnnotationSet { const ajv = new Ajv(); addFormats(ajv); - const valid = ajv.validate(readiumAnnotationModelSetJSONSchema3, data); + const valid = ajv.validate(readiumAnnotationSetSchema, data); __READIUM_ANNOTATION_AJV_ERRORS = ajv.errors?.length ? JSON.stringify(ajv.errors, null, 2) : ""; diff --git a/src/common/readium/annotation/converter.ts b/src/common/readium/annotation/converter.ts index 348fbdb84..631f3ef21 100644 --- a/src/common/readium/annotation/converter.ts +++ b/src/common/readium/annotation/converter.ts @@ -5,64 +5,262 @@ // that can be found in the LICENSE file exposed on Github (readium) in the project repository. // ==LICENSE-END== -import { IReadiumAnnotationModel, IReadiumAnnotationModelSet } from "./annotationModel.type"; +import * as debug_ from "debug"; + +import { ICssSelector, IProgressionSelector, IReadiumAnnotation, IReadiumAnnotationSet, isCssSelector, ISelector, isProgressionSelector, isTextPositionSelector, isTextQuoteSelector, ITextPositionSelector, ITextQuoteSelector } from "./annotationModel.type"; import { v4 as uuidv4 } from "uuid"; import { _APP_NAME, _APP_VERSION } from "readium-desktop/preprocessor-directives"; import { PublicationView } from "readium-desktop/common/views/publication"; import { IAnnotationState } from "readium-desktop/common/redux/states/renderer/annotation"; import { rgbToHex } from "readium-desktop/common/rgb"; +import { ICacheDocument } from "readium-desktop/common/redux/states/renderer/resourceCache"; +import { getDocumentFromICacheDocument } from "readium-desktop/utils/xmlDom"; +import { createCssSelectorMatcher, createTextPositionSelectorMatcher, createTextQuoteSelectorMatcher, describeTextPosition, describeTextQuote } from "readium-desktop/third_party/apache-annotator/dom"; +import { makeRefinable } from "readium-desktop/third_party/apache-annotator/selector"; +import { convertRange, convertRangeInfo } from "@r2-navigator-js/electron/renderer/webview/selection"; +import { MiniLocatorExtended } from "readium-desktop/common/redux/states/locatorInitialState"; +import { uniqueCssSelector as finder } from "@r2-navigator-js/electron/renderer/common/cssselector2-3"; +import { ISelectionInfo } from "@r2-navigator-js/electron/common/selection"; -export function convertAnnotationToReadiumAnnotationModel(annotation: IAnnotationState): IReadiumAnnotationModel { +// Logger +const debug = debug_("readium-desktop:common:readium:annotation:converter"); - const { uuid, color, locatorExtended: def, tags, drawType, comment, creator, created, modified } = annotation; - const { locator, headings, epubPage, selectionInfo } = def; - const { href, text, locations } = locator; - const { afterRaw, beforeRaw, highlightRaw } = text || {}; - const { rangeInfo: rangeInfoSelection } = selectionInfo || {}; - const { progression } = locations; +export async function convertSelectorTargetToLocatorExtended(target: IReadiumAnnotation["target"], cacheDoc: ICacheDocument): Promise { + + const xmlDom = getDocumentFromICacheDocument(cacheDoc); + if (!xmlDom) { + return undefined; + } + + const root = xmlDom.body; + + const textQuoteSelector = target.selector.find(isTextQuoteSelector); + const textPositionSelector = target.selector.find(isTextPositionSelector); + const cssSelector = target.selector.find(isCssSelector); + const progressionSelector = target.selector.find(isProgressionSelector); + const progressionValue = progressionSelector?.value || undefined; + + //makeRefinable + const createMatcher = makeRefinable, Node | Range, Range | Element>((selector) => { + + const innerCreateMatcher = { + "TextQuoteSelector": createTextQuoteSelectorMatcher, + "TextPositionSelector": createTextPositionSelectorMatcher, + "CssSelector": createCssSelectorMatcher, + }[selector.type]; - const highlight: IReadiumAnnotationModel["body"]["highlight"] = drawType === "solid_background" ? "solid" : drawType; + if (!innerCreateMatcher) { - const selector: IReadiumAnnotationModel["target"]["selector"] = []; + // no matcher for this selector + debug("no matcher for this selector:", selector.type); + return undefined; + } - if (highlightRaw && afterRaw && beforeRaw) { - selector.push({ - type: "TextQuoteSelector", - exact: highlightRaw, - prefix: beforeRaw, - suffix: afterRaw, - }); + return innerCreateMatcher(selector as never); + }); + + const ranges: Range[] = []; + const pushToRangeArray: (rangeOrElement: Range | Element) => void = (rangeOrElement) => { + let range: Range = undefined; + + if (rangeOrElement instanceof Element) { + range = document.createRange(); + range.selectNode(rangeOrElement); + } else { + range = rangeOrElement; + } + + ranges.push(range); + }; + if (textQuoteSelector) { + const matchAll = createMatcher(textQuoteSelector); + for await (const rangeOrElement of matchAll(root)) { + pushToRangeArray(rangeOrElement); + } } - if (progression) { - selector.push({ - type: "ProgressionSelector", - value: progression, - }); + if (textPositionSelector) { + const matchAll = createMatcher(textPositionSelector); + for await (const rangeOrElement of matchAll(root)) { + pushToRangeArray(rangeOrElement); + } } - if (rangeInfoSelection.startContainerElementCssSelector && - typeof rangeInfoSelection.startContainerChildTextNodeIndex === "number" && - rangeInfoSelection.endContainerElementCssSelector && - typeof rangeInfoSelection.endContainerChildTextNodeIndex === "number" && - typeof rangeInfoSelection.startOffset === "number" && - typeof rangeInfoSelection.endOffset === "number" - ) { - selector.push({ - type: "DomRangeSelector", - startContainerElementCssSelector: rangeInfoSelection.startContainerElementCssSelector, - startContainerChildTextNodeIndex: rangeInfoSelection.startContainerChildTextNodeIndex, - startOffset: rangeInfoSelection.startOffset, - endContainerElementCssSelector: rangeInfoSelection.endContainerElementCssSelector, - endContainerChildTextNodeIndex: rangeInfoSelection.endContainerChildTextNodeIndex, - endOffset: rangeInfoSelection.endOffset, - }); + if (cssSelector) { + const matchAll = createMatcher(cssSelector); + for await (const rangeOrElement of matchAll(root)) { + pushToRangeArray(rangeOrElement); + } + } + if (!ranges.length) { + debug("No selector found !!", JSON.stringify(target.selector, null, 4)); + return undefined; + } + debug(`${ranges.length} range(s) found !!!`); + + const convertedRangeArray: ReturnType[] = []; + + for (const range of ranges) { + // const range = normalizeRange(r); + if (range.collapsed) { + debug("RANGE COLLAPSED :( skipping..."); + continue; + } + + // the range start/end is guaranteed in document order due to the text matchers above (forward tree walk) ... but DOM Ranges are always ordered anyway (only the user / document selection object can be reversed) + const tuple = convertRange(range, (element) => finder(element, xmlDom, {root}), () => "", () => ""); + if (tuple && tuple.length === 2) { + convertedRangeArray.push(tuple); + } } - if (rangeInfoSelection.cfi) { - selector.push({ - type: "FragmentSelector", - conformsTo: "http://www.idpf.org/epub/linking/cfi/epub-cfi.html", - value: `epubcfi(${rangeInfoSelection.cfi || ""})`, // TODO not the complete cfi - }); + if (!convertedRangeArray.length) { + debug(`No selector found but ${ranges.length} found !!`, JSON.stringify(target.selector, null, 4)); + return undefined; } + debug(`${convertedRangeArray.length} range(s) converted found !!!`); + debug("dump convertedRange : ", JSON.stringify(convertedRangeArray, null, 4)); + + + // TODO: need an Heuristic to choose the range from the array, maybe check if all ranges are equal and add a priority in function of the selector + const [rangeInfo, textInfo] = convertedRangeArray[0]; + + const selectionInfo: ISelectionInfo = { + textFragment: undefined, + + rangeInfo, + + cleanBefore: textInfo.cleanBefore, + cleanText: textInfo.cleanText, + cleanAfter: textInfo.cleanAfter, + + rawBefore: textInfo.rawBefore, + rawText: textInfo.rawText, + rawAfter: textInfo.rawAfter, + }; + debug("SelectionInfo generated:", JSON.stringify(selectionInfo, null, 4)); + + const locatorExtended: MiniLocatorExtended = { + locator: { + href: cacheDoc.href, + locations: { + cssSelector: selectionInfo.rangeInfo.startContainerElementCssSelector, + rangeInfo: selectionInfo.rangeInfo, + progression: progressionValue, + }, + }, + selectionInfo: selectionInfo, + + audioPlaybackInfo: undefined, + paginationInfo: undefined, + selectionIsNew: undefined, + docInfo: undefined, + epubPage: undefined, + epubPageID: undefined, + headings: undefined, + secondWebViewHref: undefined, + }; + + return locatorExtended; +} + +export type IAnnotationStateWithICacheDocument = IAnnotationState & { __cacheDocument?: ICacheDocument | undefined }; + +const describeCssSelectorWithTextPosition = async (range: Range, document: Document, root: HTMLElement): Promise | undefined> => { + // normalizeRange can fundamentally alter the DOM Range by repositioning / snapping to Text boundaries, this is an internal implementation detail inside navigator when CREATING ranges from user document selections. + // const rangeNormalize = normalizeRange(range); // from r2-nav and not from third-party/apache-annotator + + const commonAncestorHTMLElement = + (range.commonAncestorContainer && range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE) + ? range.commonAncestorContainer as Element + : (range.startContainer.parentNode && range.startContainer.parentNode.nodeType === Node.ELEMENT_NODE) + ? range.startContainer.parentNode as Element + : undefined; + if (!commonAncestorHTMLElement) { + return undefined; + } + + return { + type: "CssSelector", + value: finder(commonAncestorHTMLElement, document, { root }), + refinedBy: await describeTextPosition( + range, + commonAncestorHTMLElement, + ), + }; +}; + +export async function convertAnnotationStateToSelector(annotationWithCacheDoc: IAnnotationStateWithICacheDocument, isLcp: boolean): Promise { + + const selector: ISelector[] = []; + + const {__cacheDocument, ...annotation} = annotationWithCacheDoc; + + const xmlDom = getDocumentFromICacheDocument(__cacheDocument); + if (!xmlDom) { + return []; + } + + const document = xmlDom; + const root = xmlDom.body; + + const { locatorExtended } = annotation; + const { selectionInfo, locator } = locatorExtended; + const { locations } = locator; + const { progression } = locations; + const { rangeInfo } = selectionInfo; + + // the range start/end is guaranteed in document order (internally used in navigator whenever deserialising DOM Ranges from JSON expression) ... but DOM Ranges are always ordered anyway (only the user / document selection object can be reversed) + const range = convertRangeInfo(xmlDom, rangeInfo); + debug("Dump range memory found:", range); + + if (range.collapsed) { + debug("RANGE COLLAPSED??! skipping..."); + return selector; + } + + // createTextPositionSelectorMatcher() + const selectorCssSelectorWithTextPosition = await describeCssSelectorWithTextPosition(range, document, root); + if (selectorCssSelectorWithTextPosition) { + + debug("CssWithTextPositionSelector : ", selectorCssSelectorWithTextPosition); + selector.push(selectorCssSelectorWithTextPosition); + } + + // describeTextPosition() + const selectorTextPosition = await describeTextPosition(range, root); + debug("TextPositionSelector : ", selectorTextPosition); + selector.push(selectorTextPosition); + + if (!isLcp) { + + // describeTextQuote() + const selectorTextQuote = await describeTextQuote(range, root); + debug("TextQuoteSelector : ", selectorTextQuote); + selector.push(selectorTextQuote); + } + + const progressionSelector: IProgressionSelector = { + type: "ProgressionSelector", + value: progression || -1, + }; + debug("ProgressionSelector : ", progressionSelector); + selector.push(progressionSelector); + + // Next TODO: CFI !?! + + return selector; +} + +export async function convertAnnotationStateToReadiumAnnotation(annotation: IAnnotationStateWithICacheDocument, isLcp: boolean): Promise { + + const { uuid, color, locatorExtended: def, tags, drawType, comment, creator, created, modified } = annotation; + const { locator, headings, epubPage/*, selectionInfo*/ } = def; + const { href /*text, locations*/ } = locator; + // const { afterRaw, beforeRaw, highlightRaw } = text || {}; + // const { rangeInfo: rangeInfoSelection } = selectionInfo || {}; + // const { progression } = locations; + + const highlight: IReadiumAnnotation["body"]["highlight"] = drawType === "solid_background" ? "solid" : drawType; + + const selector = await convertAnnotationStateToSelector(annotation, isLcp); return { "@context": "http://www.w3.org/ns/anno.jsonld", @@ -92,10 +290,11 @@ export function convertAnnotationToReadiumAnnotationModel(annotation: IAnnotatio }; } -export function convertAnnotationListToReadiumAnnotationSet(annotationArray: IAnnotationState[], publicationView: PublicationView, label?: string): IReadiumAnnotationModelSet { +export async function convertAnnotationStateArrayToReadiumAnnotationSet(annotationArray: IAnnotationStateWithICacheDocument[], publicationView: PublicationView, label?: string): Promise { const currentDate = new Date(); const dateString: string = currentDate.toISOString(); + const isLcp = !!publicationView.lcp; return { "@context": "http://www.w3.org/ns/anno.jsonld", @@ -117,6 +316,6 @@ export function convertAnnotationListToReadiumAnnotationSet(annotationArray: IAn "dc:creator": publicationView.authors || [], "dc:date": publicationView.publishedAt || "", }, - items: (annotationArray || []).map((v) => convertAnnotationToReadiumAnnotationModel(v)), + items: await Promise.all((annotationArray || []).map(async (v) => await convertAnnotationStateToReadiumAnnotation(v, isLcp))), }; } diff --git a/src/common/redux/actions/annotation/importTriggerModal.ts b/src/common/redux/actions/annotation/importTriggerModal.ts index 4d15d280a..ab773634c 100644 --- a/src/common/redux/actions/annotation/importTriggerModal.ts +++ b/src/common/redux/actions/annotation/importTriggerModal.ts @@ -6,18 +6,18 @@ // ==LICENSE-END== import { Action } from "readium-desktop/common/models/redux"; -import { IReadiumAnnotationModelSet } from "readium-desktop/common/readium/annotation/annotationModel.type"; -import { IAnnotationState } from "../../states/renderer/annotation"; +import { IReadiumAnnotationSet } from "readium-desktop/common/readium/annotation/annotationModel.type"; +import { IAnnotationPreParsingState } from "../../states/renderer/annotation"; export const ID = "ANNOTATION_IMPORT_TRIGGER_MODAL"; -export interface IReadiumAnnotationModelSetView extends Partial> { +export interface IReadiumAnnotationModelSetView extends Partial> { } export interface Payload extends IReadiumAnnotationModelSetView { - annotationsList: IAnnotationState[] - annotationsConflictListOlder: IAnnotationState[]; - annotationsConflictListNewer: IAnnotationState[]; + annotationsList: IAnnotationPreParsingState[] + annotationsConflictListOlder: IAnnotationPreParsingState[]; + annotationsConflictListNewer: IAnnotationPreParsingState[]; winId?: string | undefined; } export function build(payload: Payload): Action { diff --git a/src/common/redux/actions/annotation/index.ts b/src/common/redux/actions/annotation/index.ts index 62ca7ef8f..2f8f64dd2 100644 --- a/src/common/redux/actions/annotation/index.ts +++ b/src/common/redux/actions/annotation/index.ts @@ -8,9 +8,13 @@ import * as importAnnotationSet from "./importAnnotationSet"; import * as importTriggerModal from "./importTriggerModal"; import * as importConfirmOrAbort from "./importConfirmOrAbort"; +import * as pushToAnnotationImportQueue from "./pushToAnnotationImportQueue"; +import * as shiftFromAnnotationImportQueue from "./shiftFromAnnotationImportQueue"; export { importAnnotationSet, importTriggerModal, importConfirmOrAbort, + pushToAnnotationImportQueue, + shiftFromAnnotationImportQueue, }; diff --git a/src/main/redux/actions/win/registry/addAnnotationToReaderPublication.ts b/src/common/redux/actions/annotation/pushToAnnotationImportQueue.ts similarity index 63% rename from src/main/redux/actions/win/registry/addAnnotationToReaderPublication.ts rename to src/common/redux/actions/annotation/pushToAnnotationImportQueue.ts index d641f85c7..53efc1188 100644 --- a/src/main/redux/actions/win/registry/addAnnotationToReaderPublication.ts +++ b/src/common/redux/actions/annotation/pushToAnnotationImportQueue.ts @@ -6,22 +6,17 @@ // ==LICENSE-END== import { Action } from "readium-desktop/common/models/redux"; -import { IAnnotationState } from "readium-desktop/common/redux/states/renderer/annotation"; +import { IAnnotationPreParsingState } from "../../states/renderer/annotation"; -export const ID = "WIN_REGISTRY_REGISTER_ADD_ANNOTATION"; +export const ID = "ANNOTATION_PUSH_TO_ANNOTATION_IMPORT_QUEUE"; export interface Payload { - publicationIdentifier: string; - annotations: IAnnotationState[]; + annotations: IAnnotationPreParsingState[]; } - -export function build(publicationIdentifier: string, annotations: IAnnotationState[]): - Action { - +export function build(annotations: IAnnotationPreParsingState[]): Action { return { type: ID, payload: { - publicationIdentifier, annotations, }, }; diff --git a/src/common/redux/actions/annotation/shiftFromAnnotationImportQueue.ts b/src/common/redux/actions/annotation/shiftFromAnnotationImportQueue.ts new file mode 100644 index 000000000..7bed9e560 --- /dev/null +++ b/src/common/redux/actions/annotation/shiftFromAnnotationImportQueue.ts @@ -0,0 +1,23 @@ +// ==LICENSE-BEGIN== +// Copyright 2017 European Digital Reading Lab. All rights reserved. +// Licensed to the Readium Foundation under one or more contributor license agreements. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file exposed on Github (readium) in the project repository. +// ==LICENSE-END== + +import { ActionAcrossRenderer } from "readium-desktop/common/models/sync"; + +export const ID = "ANNOTATION_SHIFT_FROM_ANNOTATION_IMPORT_QUEUE"; + +export interface Payload { +} +export function build(): ActionAcrossRenderer { + return { + type: ID, + payload: { + }, + sendActionAcrossRenderer: true, + }; +} +build.toString = () => ID; // Redux StringableActionCreator +export type TAction = ReturnType; diff --git a/src/common/redux/states/commonRootState.ts b/src/common/redux/states/commonRootState.ts index 72720f072..a2191b7c6 100644 --- a/src/common/redux/states/commonRootState.ts +++ b/src/common/redux/states/commonRootState.ts @@ -13,6 +13,8 @@ import { ReaderConfig } from "readium-desktop/common/models/reader"; import { ITheme } from "./theme"; import { IAnnotationCreator } from "./creator"; import { I18NState } from "readium-desktop/common/redux/states/i18n"; +import { TFIFOState } from "readium-desktop/utils/redux-reducers/fifo.reducer"; +import { IAnnotationPreParsingState } from "./renderer/annotation"; export interface ICommonRootState { i18n: I18NState; @@ -25,4 +27,5 @@ export interface ICommonRootState { }; theme: ITheme; creator: IAnnotationCreator; + annotationImportQueue: TFIFOState; } diff --git a/src/common/redux/states/importAnnotation.ts b/src/common/redux/states/importAnnotation.ts index cf3f300e4..3cfb085ca 100644 --- a/src/common/redux/states/importAnnotation.ts +++ b/src/common/redux/states/importAnnotation.ts @@ -5,16 +5,16 @@ // that can be found in the LICENSE file exposed on Github (readium) in the project repository. // ==LICENSE-END== -import { IReadiumAnnotationModelSet } from "readium-desktop/common/readium/annotation/annotationModel.type"; -import { IAnnotationState } from "readium-desktop/common/redux/states/renderer/annotation"; +import { IReadiumAnnotationSet } from "readium-desktop/common/readium/annotation/annotationModel.type"; +import { IAnnotationPreParsingState } from "readium-desktop/common/redux/states/renderer/annotation"; -interface IReadiumAnnotationModelSetView extends Partial> { +interface IReadiumAnnotationModelSetView extends Partial> { } export interface IImportAnnotationState extends IReadiumAnnotationModelSetView { open: boolean; - annotationsConflictListOlder: IAnnotationState[] - annotationsConflictListNewer: IAnnotationState[] - annotationsList: IAnnotationState[] + annotationsConflictListOlder: IAnnotationPreParsingState[] + annotationsConflictListNewer: IAnnotationPreParsingState[] + annotationsList: IAnnotationPreParsingState[] winId?: string | undefined; } diff --git a/src/common/redux/states/renderer/annotation.ts b/src/common/redux/states/renderer/annotation.ts index fe8721587..8e0e7214d 100644 --- a/src/common/redux/states/renderer/annotation.ts +++ b/src/common/redux/states/renderer/annotation.ts @@ -9,6 +9,7 @@ import { MiniLocatorExtended } from "readium-desktop/common/redux/states/locator import { IPQueueState } from "readium-desktop/utils/redux-reducers/pqueue.reducer"; import { IAnnotationCreator } from "../creator"; +import { IReadiumAnnotation } from "readium-desktop/common/readium/annotation/annotationModel.type"; export interface IColor { red: number; @@ -18,6 +19,8 @@ export interface IColor { export type TDrawType = "solid_background" | "underline" | "strikethrough" | "outline"; +export type IAnnotationPreParsingState = Pick & { target: IReadiumAnnotation["target"] }; + export interface IAnnotationState { uuid: string; locatorExtended: MiniLocatorExtended; diff --git a/src/common/redux/states/renderer/readerRootState.ts b/src/common/redux/states/renderer/readerRootState.ts index dbd7f9290..d1546a9b9 100644 --- a/src/common/redux/states/renderer/readerRootState.ts +++ b/src/common/redux/states/renderer/readerRootState.ts @@ -23,11 +23,13 @@ import { IAnnotationModeState, TAnnotationState, TAnnotationTagsIndex } from "./ import { ITTSState } from "readium-desktop/renderer/reader/redux/state/tts"; import { IMediaOverlayState } from "readium-desktop/renderer/reader/redux/state/mediaOverlay"; import { IAllowCustomConfigState } from "readium-desktop/renderer/reader/redux/state/allowCustom"; +import { ICacheDocument } from "./resourceCache"; export interface IReaderRootState extends IRendererCommonRootState { reader: IReaderStateReader; picker: IPickerState; search: ISearchState; + resourceCache: ICacheDocument[]; mode: ReaderMode; annotation: IAnnotationModeState; annotationTagsIndex: TAnnotationTagsIndex; diff --git a/src/utils/search/search.interface.ts b/src/common/redux/states/renderer/resourceCache.ts similarity index 55% rename from src/utils/search/search.interface.ts rename to src/common/redux/states/renderer/resourceCache.ts index 742b66708..c88f83564 100644 --- a/src/utils/search/search.interface.ts +++ b/src/common/redux/states/renderer/resourceCache.ts @@ -5,24 +5,7 @@ // that can be found in the LICENSE file exposed on Github (readium) in the project repository. // ==LICENSE-END== -import { IRangeInfo } from "@r2-navigator-js/electron/common/selection"; - -export interface ISearchResult { - rangeInfo: IRangeInfo; - - cleanBefore: string; - cleanText: string; - cleanAfter: string; - - // rawBefore: string; - // rawText: string; - // rawAfter: string; - - href: string; - uuid: string; -} - -export interface ISearchDocument { +export interface ICacheDocument { xml: string; href: string; contentType: string; diff --git a/src/common/redux/states/renderer/search.ts b/src/common/redux/states/renderer/search.ts index 8ff202c25..b00a6990c 100644 --- a/src/common/redux/states/renderer/search.ts +++ b/src/common/redux/states/renderer/search.ts @@ -5,8 +5,7 @@ // that can be found in the LICENSE file exposed on Github (readium) in the project repository. // ==LICENSE-END== -import { ISearchDocument, ISearchResult } from "readium-desktop/utils/search/search.interface"; - +import { ISearchResult } from "readium-desktop/utils/search/search"; import { IHighlightBaseState } from "./highlight"; export interface ISearchState { @@ -16,7 +15,6 @@ export interface ISearchState { newFocusUUId: IHighlightBaseState["uuid"]; oldFocusUUId: IHighlightBaseState["uuid"]; foundArray: ISearchResult[]; - cacheArray: ISearchDocument[]; } export const searchDefaultState = (): ISearchState => @@ -27,5 +25,4 @@ export const searchDefaultState = (): ISearchState => newFocusUUId: "", oldFocusUUId: "", foundArray: [], - cacheArray: [], }); diff --git a/src/main/redux/actions/win/registry/index.ts b/src/main/redux/actions/win/registry/index.ts index 186befb75..1ceca4d70 100644 --- a/src/main/redux/actions/win/registry/index.ts +++ b/src/main/redux/actions/win/registry/index.ts @@ -7,10 +7,8 @@ import * as registerReaderPublication from "./registerReaderPublication"; import * as unregisterReaderPublication from "./unregisterReaderPublication"; -import * as addAnnotationToReaderPublication from "./addAnnotationToReaderPublication"; export { registerReaderPublication, unregisterReaderPublication, - addAnnotationToReaderPublication, }; diff --git a/src/main/redux/middleware/persistence.ts b/src/main/redux/middleware/persistence.ts index 4bf3f789d..cbe84ad58 100644 --- a/src/main/redux/middleware/persistence.ts +++ b/src/main/redux/middleware/persistence.ts @@ -43,6 +43,7 @@ export const reduxPersistMiddleware: Middleware wizard: prevState.wizard, settings: prevState.settings, creator: prevState.creator, + annotationImportQueue: prevState.annotationImportQueue, }; const persistNextState: PersistRootState = { @@ -62,6 +63,7 @@ export const reduxPersistMiddleware: Middleware wizard: nextState.wizard, settings: nextState.settings, creator: nextState.creator, + annotationImportQueue: nextState.annotationImportQueue, }; // RangeError: Maximum call stack size exceeded diff --git a/src/main/redux/middleware/sync.ts b/src/main/redux/middleware/sync.ts index 1491e9ec4..1474f596d 100644 --- a/src/main/redux/middleware/sync.ts +++ b/src/main/redux/middleware/sync.ts @@ -7,7 +7,7 @@ import * as debug_ from "debug"; import { syncIpc } from "readium-desktop/common/ipc"; -import { ActionWithDestination, ActionWithSender, SenderType } from "readium-desktop/common/models/sync"; +import { ActionAcrossRenderer, ActionWithDestination, ActionWithSender, SenderType } from "readium-desktop/common/models/sync"; import { apiActions, authActions, catalogActions, dialogActions, downloadActions, historyActions, i18nActions, keyboardActions, lcpActions, publicationActions, themeActions, @@ -87,6 +87,10 @@ const SYNCHRONIZABLE_ACTIONS: string[] = [ annotationActions.importTriggerModal.ID, // annotationActions.importConfirmOrAbort.ID, + annotationActions.pushToAnnotationImportQueue.ID, + + annotationActions.shiftFromAnnotationImportQueue.ID, + readerActions.setTheLock.ID, ]; @@ -146,6 +150,9 @@ export const reduxSyncMiddleware: Middleware !( (action as ActionWithSender).sender?.type === SenderType.Renderer && (action as ActionWithSender).sender?.identifier === id + ) || ( + (action as ActionAcrossRenderer)?.sendActionAcrossRenderer + && (action as ActionWithSender)?.sender?.identifier !== id ) ) { diff --git a/src/main/redux/reducers/index.ts b/src/main/redux/reducers/index.ts index 6afb845ed..ef5705651 100644 --- a/src/main/redux/reducers/index.ts +++ b/src/main/redux/reducers/index.ts @@ -14,7 +14,7 @@ import { priorityQueueReducer } from "readium-desktop/utils/redux-reducers/pqueu import { combineReducers } from "redux"; import { publicationActions, winActions } from "../actions"; -import { publicationActions as publicationActionsFromCommonAction } from "readium-desktop/common/redux/actions"; +import { annotationActions, publicationActions as publicationActionsFromCommonAction } from "readium-desktop/common/redux/actions"; import { lcpReducer } from "./lcp"; import { readerDefaultConfigReducer } from "../../../common/redux/reducers/reader/defaultConfig"; import { winRegistryReaderReducer } from "./win/registry/reader"; @@ -31,6 +31,8 @@ import { wizardReducer } from "readium-desktop/common/redux/reducers/wizard"; import { versionReducer } from "readium-desktop/common/redux/reducers/version"; import { creatorReducer } from "readium-desktop/common/redux/reducers/creator"; import { settingsReducer } from "readium-desktop/common/redux/reducers/settings"; +import { fifoReducer } from "readium-desktop/utils/redux-reducers/fifo.reducer"; +import { IAnnotationPreParsingState } from "readium-desktop/common/redux/states/renderer/annotation"; export const rootReducer = combineReducers({ // RootState versionUpdate: versionUpdateReducer, @@ -105,4 +107,20 @@ export const rootReducer = combineReducers({ // RootState wizard: wizardReducer, settings: settingsReducer, creator: creatorReducer, + annotationImportQueue: fifoReducer + < + annotationActions.pushToAnnotationImportQueue.TAction, + IAnnotationPreParsingState + >( + { + push: { + type: annotationActions.pushToAnnotationImportQueue.ID, + selector: (action) => action.payload.annotations, + }, + shift: { + type: annotationActions.shiftFromAnnotationImportQueue.ID, + }, + }, + ), + }); diff --git a/src/main/redux/reducers/win/registry/reader.ts b/src/main/redux/reducers/win/registry/reader.ts index eef39f51c..e69664855 100644 --- a/src/main/redux/reducers/win/registry/reader.ts +++ b/src/main/redux/reducers/win/registry/reader.ts @@ -9,15 +9,14 @@ import { type Reducer } from "redux"; import { winActions } from "readium-desktop/main/redux/actions"; import { IDictWinRegistryReaderState } from "readium-desktop/main/redux/states/win/registry/reader"; -import { IQueueAnnotationState } from "readium-desktop/common/redux/states/renderer/annotation"; +// import { IQueueAnnotationState } from "readium-desktop/common/redux/states/renderer/annotation"; const initialState: IDictWinRegistryReaderState = {}; function winRegistryReaderReducer_( state: IDictWinRegistryReaderState = initialState, action: winActions.registry.registerReaderPublication.TAction - | winActions.registry.unregisterReaderPublication.TAction - | winActions.registry.addAnnotationToReaderPublication.TAction, + | winActions.registry.unregisterReaderPublication.TAction, ): IDictWinRegistryReaderState { switch (action.type) { @@ -50,39 +49,6 @@ function winRegistryReaderReducer_( return state; } - case winActions.registry.addAnnotationToReaderPublication.ID: { - - const { publicationIdentifier: id, annotations } = action.payload; - - if (annotations.length && Array.isArray(state[id]?.reduxState?.annotation)) { - - const oldAnno = state[id].reduxState.annotation; - const oldAnnoUniq = oldAnno.filter(([, {uuid}]) => !annotations.find(({uuid: uuid2}) => uuid2 === uuid)); - const newAnno = annotations.map((anno) => [anno.created || (new Date()).getTime(), anno]); - return { - ...state, - ...{ - [id]: { - ...state[id], - ...{ - reduxState: { - ...state[id].reduxState, - ...{ - annotation: [ - ...oldAnnoUniq, - ...newAnno, - ], - }, - }, - }, - }, - }, - }; - - } - return state; - } - default: return state; } diff --git a/src/main/redux/sagas/annotation.ts b/src/main/redux/sagas/annotation.ts index f278af0af..a6c105f02 100644 --- a/src/main/redux/sagas/annotation.ts +++ b/src/main/redux/sagas/annotation.ts @@ -9,21 +9,16 @@ import * as debug_ from "debug"; import { dialog } from "electron"; import { readFile } from "fs/promises"; import { ToastType } from "readium-desktop/common/models/toast"; -import { annotationActions, readerActions, toastActions } from "readium-desktop/common/redux/actions"; +import { annotationActions, toastActions } from "readium-desktop/common/redux/actions"; import { getLibraryWindowFromDi, getReaderWindowFromDi } from "readium-desktop/main/di"; import { error } from "readium-desktop/main/tools/error"; import { SagaGenerator } from "typed-redux-saga"; import { call as callTyped, put as putTyped, select as selectTyped, take as takeTyped } from "typed-redux-saga/macro"; -import { IAnnotationState } from "readium-desktop/common/redux/states/renderer/annotation"; +import { IAnnotationPreParsingState, IAnnotationState } from "readium-desktop/common/redux/states/renderer/annotation"; import { hexToRgb } from "readium-desktop/common/rgb"; import { isNil } from "readium-desktop/utils/nil"; import { RootState } from "../states"; -import { __READIUM_ANNOTATION_AJV_ERRORS, isDomRangeSelector, isFragmentSelector, isIReadiumAnnotationModelSet, isProgressionSelector, isTextQuoteSelector } from "readium-desktop/common/readium/annotation/annotationModel.type"; -import { ActionSerializer } from "readium-desktop/common/services/serializer"; -import { syncIpc } from "readium-desktop/common/ipc"; -import { SenderType } from "readium-desktop/common/models/sync"; -import { winActions } from "readium-desktop/main/redux/actions"; -import { cleanupStr } from "readium-desktop/utils/search/transliteration"; +import { __READIUM_ANNOTATION_AJV_ERRORS, isCFIFragmentSelector, isFragmentSelector, isIReadiumAnnotationSet, isTextPositionSelector, isTextQuoteSelector } from "readium-desktop/common/readium/annotation/annotationModel.type"; import path from "path"; import { getPublication } from "./api/publication/getPublication"; import { Publication as R2Publication } from "@r2-shared-js/models/publication"; @@ -52,7 +47,7 @@ function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAct if (!win || win.isDestroyed() || win.webContents.isDestroyed()) { debug("ERROR!! No Browser window !!! exit"); - return ; + return; } let filePath = ""; @@ -68,7 +63,7 @@ function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAct } catch (e) { debug("Error!!! to open a file, exit", e); yield* putTyped(toastActions.openRequest.build(ToastType.Error, "" + e, readerPublicationIdentifier)); - return ; + return; } debug("FilePath=", filePath); @@ -77,339 +72,244 @@ function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAct try { // read filePath - const data = yield* callTyped(() => readFile(filePath, { encoding: "utf8" })); - const readiumAnnotationFormat = JSON.parse(data); - debug("filePath size=", data.length); + const dataString = yield* callTyped(() => readFile(filePath, { encoding: "utf8" })); + const readiumAnnotationFormat = JSON.parse(dataString); + debug("filePath size=", dataString.length); debug("filePath serialized and ready to pass the type checker"); - if (isIReadiumAnnotationModelSet(readiumAnnotationFormat)) { + if (!isIReadiumAnnotationSet(readiumAnnotationFormat)) { - debug("filePath pass the typeChecker (ReadiumAnnotationModelSet)"); + debug("Error: ", __READIUM_ANNOTATION_AJV_ERRORS); + yield* putTyped(toastActions.openRequest.build(ToastType.Error, __("message.annotations.errorParsing"), readerPublicationIdentifier)); + return; + } - const data = readiumAnnotationFormat; - const annotationsIncommingArray = data.items; + debug("filePath pass the typeChecker (ReadiumAnnotationModelSet)"); - if (!annotationsIncommingArray.length) { - debug("there are no annotations in the file, exit"); - yield* putTyped(toastActions.openRequest.build(ToastType.Success, __("message.annotations.emptyFile"), readerPublicationIdentifier)); - return; - } + const data = readiumAnnotationFormat; + const annotationsIncommingArray = data.items; + if (!annotationsIncommingArray.length) { + debug("there are no annotations in the file, exit"); + yield* putTyped(toastActions.openRequest.build(ToastType.Success, __("message.annotations.emptyFile"), readerPublicationIdentifier)); + return; + } - // we just check if each annotation href source belongs to the R2Publication Spine items - // if at least one annotation in the list doesn't match with the current spine item, then reject the set importation - const pubView = yield* callTyped(getPublication, publicationIdentifier); - const r2PublicationJson = pubView.r2PublicationJson; - const r2Publication = TaJsonDeserialize(r2PublicationJson, R2Publication); - const spineItem = r2Publication.Spine; - const hrefFromSpineItem = spineItem.map((v) => v.Href); - debug("Current Publcation (", publicationIdentifier, ") SpineItems(hrefs):", hrefFromSpineItem); - const annotationsIncommingArraySourceHrefs = annotationsIncommingArray.map(({ target: {source} }) => source); - debug("Incomming Annotations target.source(hrefs):", annotationsIncommingArraySourceHrefs); - const annotationsIncommingMatchPublicationSpineItem = annotationsIncommingArraySourceHrefs.reduce((acc, source) => { - return acc && hrefFromSpineItem.includes(source); - }, true); + // we just check if each annotation href source belongs to the R2Publication Spine items + // if at least one annotation in the list doesn't match with the current spine item, then reject the set importation - if (annotationsIncommingMatchPublicationSpineItem) { + const pubView = yield* callTyped(getPublication, publicationIdentifier); + const r2PublicationJson = pubView.r2PublicationJson; + const r2Publication = TaJsonDeserialize(r2PublicationJson, R2Publication); + const spineItem = r2Publication.Spine; + const hrefFromSpineItem = spineItem.map((v) => v.Href); + debug("Current Publcation (", publicationIdentifier, ") SpineItems(hrefs):", hrefFromSpineItem); + const annotationsIncommingArraySourceHrefs = annotationsIncommingArray.map(({ target: { source } }) => source); + debug("Incomming Annotations target.source(hrefs):", annotationsIncommingArraySourceHrefs); + const annotationsIncommingMatchPublicationSpineItem = annotationsIncommingArraySourceHrefs.reduce((acc, source) => { + return acc && hrefFromSpineItem.includes(source); + }, true); - debug("GOOD ! spineItemHref matched : publication identified, let's continue the importation"); + if (!annotationsIncommingMatchPublicationSpineItem) { - // OK publication identified + debug("ERROR: At least one annotation is rejected and not match with the current publication SpineItem, see above"); + yield* putTyped(toastActions.openRequest.build(ToastType.Error, __("message.annotations.noBelongTo"), readerPublicationIdentifier)); + return; + } - let annotations: IAnnotationState[] = []; - const sessionReader = yield* selectTyped((state: RootState) => state.win.session.reader); - const winSessionReaderStateArray = Object.values(sessionReader).filter((v) => v.publicationIdentifier === publicationIdentifier); - if (winSessionReaderStateArray.length) { - const winSessionReaderState = winSessionReaderStateArray[0]; - annotations = (winSessionReaderState?.reduxState?.annotation || []).map(([, v]) => v); + debug("GOOD ! spineItemHref matched : publication identified, let's continue the importation"); - debug("current publication AnnotationsList come from the readerSession (there are one or many readerWin currently open)"); - } else { - const sessionRegistry = yield* selectTyped((state: RootState) => state.win.registry.reader); - if (Object.keys(sessionRegistry).find((v) => v === publicationIdentifier)) { - annotations = (sessionRegistry[publicationIdentifier]?.reduxState?.annotation || []).map(([, v]) => v); + // OK publication identified - debug("current publication AnnotationsList come from the readerRegistry (no readerWin currently open)"); - } - } - debug("There are", annotations.length, "annotation(s) loaded from the current publicationIdentifier"); - if (!annotations.length) { - debug("Be careful, there are no annotation loaded for this publication!"); - } + let annotations: IAnnotationState[] = []; + const sessionReader = yield* selectTyped((state: RootState) => state.win.session.reader); + const winSessionReaderStateArray = Object.values(sessionReader).filter((v) => v.publicationIdentifier === publicationIdentifier); + if (winSessionReaderStateArray.length) { + const winSessionReaderStateFirst = winSessionReaderStateArray[0]; // TODO: get the first only !?! + annotations = (winSessionReaderStateFirst?.reduxState?.annotation || []).map(([, v]) => v); + debug("current publication AnnotationsList come from the readerSession (there are one or many readerWin currently open)"); + } else { + const sessionRegistry = yield* selectTyped((state: RootState) => state.win.registry.reader); + if (Object.keys(sessionRegistry).find((v) => v === publicationIdentifier)) { + annotations = (sessionRegistry[publicationIdentifier]?.reduxState?.annotation || []).map(([, v]) => v); - const annotationsParsedNoConflictArray: IAnnotationState[] = []; - const annotationsParsedConflictOlderArray: IAnnotationState[] = []; - const annotationsParsedConflictNewerArray: IAnnotationState[] = []; - const annotationsParsedAllArray: IAnnotationState[] = []; - - debug("There are", annotationsIncommingArray.length, "incomming annotations to be imported"); - - // loop on each annotation to check conflicts and import it - for (const incommingAnnotation of annotationsIncommingArray) { - - const textQuoteSelector = incommingAnnotation.target.selector.find(isTextQuoteSelector); - const progressionSelector = incommingAnnotation.target.selector.find(isProgressionSelector); - const domRangeSelector = incommingAnnotation.target.selector.find(isDomRangeSelector); - const fragmentSelector = incommingAnnotation.target.selector.find(isFragmentSelector); - const { headings, page } = incommingAnnotation.target.meta || {}; - const creator = incommingAnnotation.creator; - - const cfi = fragmentSelector.conformsTo === "http://www.idpf.org/epub/linking/cfi/epub-cfi.html" - ? fragmentSelector.value.startsWith("epubcfi(") - ? fragmentSelector.value.slice("epubcfi(".length, -1) - : fragmentSelector.value - : undefined; - const firstPartOfCfi = cfi.split(",")[0]; // TODO need to check cfi computation - - const annotationParsed: IAnnotationState = { - uuid: incommingAnnotation.id.split("urn:uuid:")[1] || uuidv4(), // TODO : may not be an uuid format and maybe we should hash the uuid to get a unique identifier based on the original uuid - locatorExtended: { - locator: { - href: incommingAnnotation.target.source, - title: undefined, - text: { - beforeRaw: textQuoteSelector?.prefix, - afterRaw: textQuoteSelector?.suffix, - highlightRaw: textQuoteSelector?.exact, - before: textQuoteSelector?.prefix ? cleanupStr(textQuoteSelector.prefix) : undefined, - after: textQuoteSelector?.suffix ? cleanupStr(textQuoteSelector.suffix) : undefined, - highlight: textQuoteSelector?.exact ? cleanupStr(textQuoteSelector.exact) : undefined, - }, - locations: { - cfi: firstPartOfCfi, - xpath: undefined, - cssSelector: domRangeSelector.startContainerElementCssSelector, // TODO just for debug, need to understand how to get this information if needed - position: undefined, - progression: progressionSelector?.value, - rangeInfo: domRangeSelector - ? { - startContainerElementCssSelector: domRangeSelector.startContainerElementCssSelector, - startContainerElementCFI: undefined, - startContainerElementXPath: undefined, - startContainerChildTextNodeIndex: domRangeSelector.startContainerChildTextNodeIndex, - startOffset: domRangeSelector.startOffset, - endContainerElementCssSelector: domRangeSelector.endContainerElementCssSelector, - endContainerElementCFI: undefined, - endContainerElementXPath: undefined, - endContainerChildTextNodeIndex: domRangeSelector.endContainerChildTextNodeIndex, - endOffset: domRangeSelector.endOffset, - cfi: cfi, - } - : undefined, - }, - }, - audioPlaybackInfo: undefined, - paginationInfo: undefined, - selectionInfo: { - textFragment: undefined, - - rawBefore: textQuoteSelector?.prefix, - rawAfter: textQuoteSelector?.suffix, - rawText: textQuoteSelector?.exact, - cleanAfter: textQuoteSelector?.prefix ? cleanupStr(textQuoteSelector.prefix) : undefined, - cleanBefore: textQuoteSelector?.suffix ? cleanupStr(textQuoteSelector.suffix) : undefined, - cleanText: textQuoteSelector?.exact ? cleanupStr(textQuoteSelector.exact) : undefined, - rangeInfo: { - startContainerElementCssSelector: domRangeSelector.startContainerElementCssSelector, - startContainerElementCFI: undefined, - startContainerElementXPath: undefined, - startContainerChildTextNodeIndex: domRangeSelector.startContainerChildTextNodeIndex, - startOffset: domRangeSelector.startOffset, - endContainerElementCssSelector: domRangeSelector.endContainerElementCssSelector, - endContainerElementCFI: undefined, - endContainerElementXPath: undefined, - endContainerChildTextNodeIndex: domRangeSelector.endContainerChildTextNodeIndex, - endOffset: domRangeSelector.endOffset, - cfi: cfi, - }, - }, - selectionIsNew: false, - docInfo: undefined, // {isFixedLayout: false, isRightToLeft: false, isVerticalWritingMode: false}, // TODO how to complete these informations - epubPage: page, - epubPageID: undefined, - headings: headings.map(({ txt, level }) => ({ id: undefined as string | undefined, txt, level })), - secondWebViewHref: undefined, - }, - comment: incommingAnnotation.body.value, - color: hexToRgb(incommingAnnotation.body.color), - drawType: (isNil(incommingAnnotation.body.highlight) || incommingAnnotation.body.highlight === "solid") ? "solid_background" : incommingAnnotation.body.highlight, - // TODO need to ask to user if the incomming tag is kept or the fileName is used - tags: [fileName], // incommingAnnotation.body.tag ? [incommingAnnotation.body.tag] : [], - modified: incommingAnnotation.modified ? tryCatchSync(() => new Date(incommingAnnotation.modified).getTime(), fileName) : undefined, - created: tryCatchSync(() => new Date(incommingAnnotation.created).getTime(), fileName) || currentTimestamp, - creator: creator ? { - id: creator.id, - type: creator.type, - name: creator.name, - } : undefined, - }; - - if (annotationParsed.modified) { - if (annotationParsed.modified > currentTimestamp) { - annotationParsed.modified = currentTimestamp; - } - if (annotationParsed.created > annotationParsed.modified) { - annotationParsed.modified = currentTimestamp; - } - } - if (annotationParsed.created > currentTimestamp) { - annotationParsed.created = currentTimestamp; - } + debug("current publication AnnotationsList come from the readerRegistry (no readerWin currently open)"); + } + } + debug("There are", annotations.length, "annotation(s) loaded from the current publicationIdentifier"); + if (!annotations.length) { + debug("Be careful, there are no annotation loaded for this publication!"); + } - debug("incomming annotation Parsed And Formated (", annotationParsed.uuid, "), and now ready to be imported in the publication!"); - debug(JSON.stringify(annotationParsed)); - annotationsParsedAllArray.push(annotationParsed); + const annotationsParsedNoConflictArray: IAnnotationPreParsingState[] = []; + const annotationsParsedConflictOlderArray: IAnnotationPreParsingState[] = []; + const annotationsParsedConflictNewerArray: IAnnotationPreParsingState[] = []; + const annotationsParsedAllArray: IAnnotationPreParsingState[] = []; - const annotationSameUUIDFound = annotations.find(({ uuid }) => uuid === annotationParsed.uuid); - if (annotationSameUUIDFound) { + debug("There are", annotationsIncommingArray.length, "incomming annotations to be imported"); - if (annotationSameUUIDFound.modified && annotationParsed.modified) { + // loop on each annotation to check conflicts and import it + for (const incommingAnnotation of annotationsIncommingArray) { - if (annotationSameUUIDFound.modified < annotationParsed.modified) { - annotationsParsedConflictNewerArray.push(annotationParsed); - } - if (annotationSameUUIDFound.modified > annotationParsed.modified) { - annotationsParsedConflictOlderArray.push(annotationParsed); - } + const textQuoteSelector = incommingAnnotation.target.selector.find(isTextQuoteSelector); + const textPositionSelector = incommingAnnotation.target.selector.find(isTextPositionSelector); + const fragmentSelectorArray = incommingAnnotation.target.selector.filter(isFragmentSelector); + const cfiFragmentSelector = fragmentSelectorArray.find(isCFIFragmentSelector); + const creator = incommingAnnotation.creator; + const uuid = incommingAnnotation.id.split("urn:uuid:")[1] || uuidv4(); // TODO : may not be an uuid format and maybe we should hash the uuid to get a unique identifier based on the original uuid - } else if (annotationSameUUIDFound.modified) { - annotationsParsedConflictOlderArray.push(annotationParsed); + if (cfiFragmentSelector) { + debug(`for ${uuid} a CFI selector is available (${JSON.stringify(cfiFragmentSelector, null, 4)})`); + } - } else if (annotationParsed.modified) { - annotationsParsedConflictNewerArray.push(annotationParsed); - } - } else { + // check if thorium selector available + if (!(textQuoteSelector || textPositionSelector)) { + debug(`for ${uuid} no selector available (TextQuote/TextPosition)`); + continue; + } - annotationsParsedNoConflictArray.push(annotationParsed); - } + const annotationParsed: IAnnotationPreParsingState = { + uuid, + target: incommingAnnotation.target, + comment: incommingAnnotation.body.value, + color: hexToRgb(incommingAnnotation.body.color), + drawType: (isNil(incommingAnnotation.body.highlight) || incommingAnnotation.body.highlight === "solid") ? "solid_background" : incommingAnnotation.body.highlight, + // TODO need to ask to user if the incomming tag is kept or the fileName is used + tags: [fileName], // incommingAnnotation.body.tag ? [incommingAnnotation.body.tag] : [], + modified: incommingAnnotation.modified ? tryCatchSync(() => new Date(incommingAnnotation.modified).getTime(), fileName) : undefined, + created: tryCatchSync(() => new Date(incommingAnnotation.created).getTime(), fileName) || currentTimestamp, + creator: creator ? { + id: creator.id, + type: creator.type, + name: creator.name, + } : undefined, + }; + + if (annotationParsed.modified) { + if (annotationParsed.modified > currentTimestamp) { + annotationParsed.modified = currentTimestamp; + } + if (annotationParsed.created > annotationParsed.modified) { + annotationParsed.modified = currentTimestamp; } + } + if (annotationParsed.created > currentTimestamp) { + annotationParsed.created = currentTimestamp; + } - if (!annotationsParsedAllArray.length) { + debug("incomming annotation Parsed And Formated (", annotationParsed.uuid, "), and now ready to be imported in the publication!"); + debug(JSON.stringify(annotationParsed)); - debug("there are no annotations ready to be imported, exit"); - yield* putTyped(toastActions.openRequest.build(ToastType.Success, __("message.annotations.nothing"), readerPublicationIdentifier)); - return; + annotationsParsedAllArray.push(annotationParsed); - } + const annotationSameUUIDFound = annotations.find(({ uuid }) => uuid === annotationParsed.uuid); + if (annotationSameUUIDFound) { - if (!(annotationsParsedConflictNewerArray.length || annotationsParsedConflictOlderArray.length || annotationsParsedNoConflictArray.length)) { + if (annotationSameUUIDFound.modified && annotationParsed.modified) { - debug("all annotations are already imported, exit"); - yield* putTyped(toastActions.openRequest.build(ToastType.Success, __("message.annotations.alreadyImported"), readerPublicationIdentifier)); - return; - } + if (annotationSameUUIDFound.modified < annotationParsed.modified) { + annotationsParsedConflictNewerArray.push(annotationParsed); + } + if (annotationSameUUIDFound.modified > annotationParsed.modified) { + annotationsParsedConflictOlderArray.push(annotationParsed); + } + } else if (annotationSameUUIDFound.modified) { + annotationsParsedConflictOlderArray.push(annotationParsed); - // dispatch data to the user modal - yield* putTyped(annotationActions.importTriggerModal.build( - { - about: data.about ? {...data.about} : undefined, - title: data.title || "", - generated: data.generated || "", - generator: data.generator ? { ...data.generator} : undefined, - annotationsList: annotationsParsedNoConflictArray, - annotationsConflictListOlder: annotationsParsedConflictOlderArray, - annotationsConflictListNewer: annotationsParsedConflictNewerArray, - winId, - }, - )); - - // wait the modal confirmation or abort - const actionConfirmOrAbort = yield* takeTyped(annotationActions.importConfirmOrAbort.build); // not .ID because we need Action return type - if (!actionConfirmOrAbort?.payload || actionConfirmOrAbort.payload.state === "abort") { - // aborted - - debug("ABORTED, exit"); - return; + } else if (annotationParsed.modified) { + annotationsParsedConflictNewerArray.push(annotationParsed); } + } else { - const annotationsParsedConflictNeedToBeUpdated = [...annotationsParsedConflictNewerArray, ...annotationsParsedConflictOlderArray]; - const annotationsParsedReadyToBeImportedArray = actionConfirmOrAbort.payload.state === "importNoConflict" - ? annotationsParsedNoConflictArray - : [...annotationsParsedNoConflictArray, ...annotationsParsedConflictOlderArray, ...annotationsParsedConflictNewerArray]; - - - debug("ready to send", annotationsParsedReadyToBeImportedArray.length, "annotation(s) to the reader"); - if (winSessionReaderStateArray.length) { - - debug("send to", winSessionReaderStateArray.length, "readerWin with the same publicationId (", publicationIdentifier, ")"); - - for (const winSessionReaderState of winSessionReaderStateArray) { - - const readerWin = getReaderWindowFromDi(winSessionReaderState.identifier); - - if (actionConfirmOrAbort.payload.state === "importAll") { - - for (const annotationToUpdate of annotationsParsedConflictNeedToBeUpdated) { - const annotationToUpdateOld = annotations.find(({ uuid }) => uuid === annotationToUpdate.uuid); - const a = ActionSerializer.serialize(readerActions.annotation.update.build(annotationToUpdateOld, annotationToUpdate)); - try { - if (readerWin && !readerWin.isDestroyed() && !readerWin.webContents.isDestroyed()) { - readerWin.webContents.send(syncIpc.CHANNEL, { - type: syncIpc.EventType.MainAction, - payload: { - action: a, - }, - sender: { - type: SenderType.Main, - }, - } as syncIpc.EventPayload); - } - - } catch (error) { - debug("ERROR in SYNC ACTION", error); - } - } - } - - for (const annotationParsedReadyToBeImported of annotationsParsedNoConflictArray) { - const a = ActionSerializer.serialize(readerActions.annotation.push.build(annotationParsedReadyToBeImported)); - try { - if (readerWin && !readerWin.isDestroyed() && !readerWin.webContents.isDestroyed()) { - readerWin.webContents.send(syncIpc.CHANNEL, { - type: syncIpc.EventType.MainAction, - payload: { - action: a, - }, - sender: { - type: SenderType.Main, - }, - } as syncIpc.EventPayload); - } - } catch (error) { - debug("ERROR in SYNC ACTION", error); - } - } - } + annotationsParsedNoConflictArray.push(annotationParsed); + } + } - } else { - // No readerWin opened with the pubId - // Need to dispatch to the reader registry to save the new annotation + if (!annotationsParsedAllArray.length) { - debug("No readerWin currently open"); - debug("Dispatch an action to save to the publicationIdentifier registry"); - debug("new AnnotationList is appended to the persisted publication reduxState in main process"); + debug("there are no annotations ready to be imported, exit"); + yield* putTyped(toastActions.openRequest.build(ToastType.Success, __("message.annotations.nothing"), readerPublicationIdentifier)); + return; - yield* putTyped(winActions.registry.addAnnotationToReaderPublication.build(publicationIdentifier, annotationsParsedReadyToBeImportedArray)); + } - } + if (!(annotationsParsedConflictNewerArray.length || annotationsParsedConflictOlderArray.length || annotationsParsedNoConflictArray.length)) { - } else { + debug("all annotations are already imported, exit"); + yield* putTyped(toastActions.openRequest.build(ToastType.Success, __("message.annotations.alreadyImported"), readerPublicationIdentifier)); + return; + } - debug("ERROR: At least one annotation is rejected and not match with the current publication SpineItem, see above"); - yield* putTyped(toastActions.openRequest.build(ToastType.Error, __("message.annotations.noBelongTo"), readerPublicationIdentifier)); - return; - } - } else { - debug("Error: ", __READIUM_ANNOTATION_AJV_ERRORS); - yield* putTyped(toastActions.openRequest.build(ToastType.Error, __("message.annotations.errorParsing"), readerPublicationIdentifier)); + // dispatch data to the user modal + yield* putTyped(annotationActions.importTriggerModal.build( + { + about: data.about ? { ...data.about } : undefined, + title: data.title || "", + generated: data.generated || "", + generator: data.generator ? { ...data.generator } : undefined, + annotationsList: annotationsParsedNoConflictArray, + annotationsConflictListOlder: annotationsParsedConflictOlderArray, + annotationsConflictListNewer: annotationsParsedConflictNewerArray, + winId, + }, + )); + + // wait the modal confirmation or abort + const actionConfirmOrAbort = yield* takeTyped(annotationActions.importConfirmOrAbort.build); // not .ID because we need Action return type + if (!actionConfirmOrAbort?.payload || actionConfirmOrAbort.payload.state === "abort") { + // aborted + + debug("ABORTED, exit"); return; } + // const annotationsParsedConflictNeedToBeUpdated = [...annotationsParsedConflictNewerArray, ...annotationsParsedConflictOlderArray]; + const annotationsParsedReadyToBeImportedArray = actionConfirmOrAbort.payload.state === "importNoConflict" + ? annotationsParsedNoConflictArray + : [...annotationsParsedNoConflictArray, ...annotationsParsedConflictOlderArray, ...annotationsParsedConflictNewerArray]; + + debug("ready to send", annotationsParsedReadyToBeImportedArray.length, "annotation(s) to the annotationImportQueue processed to the reader"); + + + // need to develop a FIFO queue because more that one same notes can be pushed to the queue + + let delta = 0; + { + const annotationQueue = yield* selectTyped((_state: RootState) => _state.annotationImportQueue); + debug("AnnotationImportQueue length: ", annotationQueue.length); + delta = -1 * annotationQueue.length; + } + + yield* putTyped(annotationActions.pushToAnnotationImportQueue.build(annotationsParsedReadyToBeImportedArray)); + + // finish !!! + + { + const annotationQueue = yield* selectTyped((_state: RootState) => _state.annotationImportQueue); + debug("AnnotationImportQueue length: ", annotationQueue.length); + delta += annotationQueue.length; + + if (delta === annotationsParsedReadyToBeImportedArray.length) { + debug(`${delta} new annotations adedd to the import queue`); + } else { + debug(`Error not all annotations have been added to the queue : ${delta} vs ${annotationsParsedReadyToBeImportedArray.length} !!!`); + debug(JSON.stringify(annotationsParsedReadyToBeImportedArray, null, 4)); + } + } + + + // then in reader window that got the lock, parse and import notes + // annotImportQUeue[0], get the first element and then dispatch to unshift from the state myaction.shift.build()... FIFO queue + + // convert range to locator IRangeInfo/selectionInfo + // ref: https://github.com/readium/r2-navigator-js/blob/a08126622ac87e04200a178cc438fd7e1b256c52/src/electron/renderer/webview/selection.ts#L342 + + } catch (e) { debug("Error to read the file: ", e); if (e?.path !== "") { @@ -420,7 +320,7 @@ function* importAnnotationSet(action: annotationActions.importAnnotationSet.TAct debug("Annotations importer success and exit"); yield* putTyped(toastActions.openRequest.build(ToastType.Success, __("message.annotations.success"), readerPublicationIdentifier)); - return ; + return; } diff --git a/src/main/redux/sagas/persist.ts b/src/main/redux/sagas/persist.ts index 19b606577..648a3bfbb 100644 --- a/src/main/redux/sagas/persist.ts +++ b/src/main/redux/sagas/persist.ts @@ -43,6 +43,7 @@ const persistStateToFs = async (nextState: RootState) => { wizard: nextState.wizard, settings: nextState.settings, creator: nextState.creator, + annotationImportQueue: nextState.annotationImportQueue, }; await fsp.writeFile(stateFilePath, JSON.stringify(value), {encoding: "utf8"}); diff --git a/src/main/redux/sagas/win/reader.ts b/src/main/redux/sagas/win/reader.ts index 99da4bea0..d6355e7d9 100644 --- a/src/main/redux/sagas/win/reader.ts +++ b/src/main/redux/sagas/win/reader.ts @@ -47,6 +47,7 @@ function* winOpen(action: winActions.reader.openSucess.TAction) { const config = reader?.reduxState?.config || readerConfigInitialState; const transientConfigMerge = {...readerConfigInitialState, ...config}; const creator = yield* selectTyped((_state: RootState) => _state.creator); + const annotationImportQueue = yield* selectTyped((_state: RootState) => _state.annotationImportQueue); const publicationRepository = diMainGet("publication-repository"); let tag: string[] = []; @@ -106,6 +107,7 @@ function* winOpen(action: winActions.reader.openSucess.TAction) { publication: { tag, }, + annotationImportQueue, }, } as readerIpc.EventPayload); } diff --git a/src/main/redux/states/index.ts b/src/main/redux/states/index.ts index 0d6fad814..ad2890185 100644 --- a/src/main/redux/states/index.ts +++ b/src/main/redux/states/index.ts @@ -53,4 +53,4 @@ export interface RootState extends ICommonRootState { settings: ISettingsState; } -export type PersistRootState = Pick; +export type PersistRootState = Pick; diff --git a/src/renderer/common/components/ImportAnnotationsDialog.tsx b/src/renderer/common/components/ImportAnnotationsDialog.tsx index 491822f42..45b74cd8f 100644 --- a/src/renderer/common/components/ImportAnnotationsDialog.tsx +++ b/src/renderer/common/components/ImportAnnotationsDialog.tsx @@ -21,7 +21,7 @@ import * as PlusIcon from "readium-desktop/renderer/assets/icons/Plus-bold.svg"; export const ImportAnnotationsDialog: React.FC> = (props) => { const importAnnotationState = useSelector((state: IRendererCommonRootState) => state.importAnnotations); - const { open, title, annotationsList, annotationsConflictListOlder, annotationsConflictListNewer, winId } = importAnnotationState; + const { open, title, annotationsList, annotationsConflictListOlder, annotationsConflictListNewer, winId, about } = importAnnotationState; const { publicationView } = props; const { publicationTitle, authors, identifier } = publicationView; const dispatch = useDispatch(); @@ -44,6 +44,9 @@ export const ImportAnnotationsDialog: React.FC { if (requestOpen) { @@ -65,10 +68,11 @@ export const ImportAnnotationsDialog: React.FC {__("dialog.annotations.title")} - {title ? `${__("dialog.annotations.descTitle")}${title}` : ""} + {originTitle ? __("dialog.annotations.origin", { title: originTitle, author: originCreator ? __("dialog.annotations.descAuthor", { author: originCreator }) : "" }) : ""} + {title ? `${__("dialog.annotations.descTitle")}'${title}'` : ""} {annotationsList.length ? __("dialog.annotations.descList", { nb: annotationsList.length, - creator: creatorNameList.length ? creatorNameList.join(", ") : "\"\"", + creator: creatorNameList.length ? `${__("dialog.annotations.descCreator")} '${creatorNameList.join(", ")}'` : "", // TODO i18n title: publicationTitle, author: authors[0] ? __("dialog.annotations.descAuthor", { author: authors[0] }) : "", }) : <>} diff --git a/src/renderer/reader/components/ReaderMenu.tsx b/src/renderer/reader/components/ReaderMenu.tsx index 28f1a5118..6cafe6daf 100644 --- a/src/renderer/reader/components/ReaderMenu.tsx +++ b/src/renderer/reader/components/ReaderMenu.tsx @@ -85,13 +85,11 @@ import { useDispatch } from "readium-desktop/renderer/common/hooks/useDispatch"; import { Locator } from "@r2-shared-js/models/locator"; import { IAnnotationState, IColor, TAnnotationState, TDrawType } from "readium-desktop/common/redux/states/renderer/annotation"; import { readerActions } from "readium-desktop/common/redux/actions"; -import { readerLocalActionLocatorHrefChanged, readerLocalActionSetConfig } from "../redux/actions"; +import { readerLocalActionExportAnnotationSet, readerLocalActionLocatorHrefChanged, readerLocalActionSetConfig } from "../redux/actions"; import { useReaderConfig, useSaveReaderConfig } from "readium-desktop/renderer/common/hooks/useReaderConfig"; import { ReaderConfig } from "readium-desktop/common/models/reader"; import { ObjectKeys } from "readium-desktop/utils/object-keys-values"; import { rgbToHex } from "readium-desktop/common/rgb"; -import { IReadiumAnnotationModelSet } from "readium-desktop/common/readium/annotation/annotationModel.type"; -import { convertAnnotationListToReadiumAnnotationSet } from "readium-desktop/common/readium/annotation/converter"; import { ImportAnnotationsDialog } from "readium-desktop/renderer/common/components/ImportAnnotationsDialog"; import { IBookmarkState } from "readium-desktop/common/redux/states/bookmark"; import { IReaderRootState } from "readium-desktop/common/redux/states/renderer/readerRootState"; @@ -644,7 +642,7 @@ const AnnotationCard: React.FC<{ timestamp: number, annotation: IAnnotationState : - - + @@ -2514,11 +2498,11 @@ export const ReaderMenu: React.FC = (props) => {
- { + + return { + type: ID, + payload: { + annotationArray, + publicationView, + label, + }, + }; +} +build.toString = () => ID; // Redux StringableActionCreator +export type TAction = ReturnType; diff --git a/src/renderer/reader/redux/actions/index.ts b/src/renderer/reader/redux/actions/index.ts index 24e7f6501..f65a5a2ff 100644 --- a/src/renderer/reader/redux/actions/index.ts +++ b/src/renderer/reader/redux/actions/index.ts @@ -15,8 +15,12 @@ import * as readerLocalActionSetConfig from "./setConfig"; import * as readerLocalActionSetTransientConfig from "./setTransientConfig"; import * as readerLocalActionSetLocator from "./setLocator"; import * as readerLocalActionReader from "./reader"; +import * as readerLocalActionSetResourceToCache from "./resourceCache"; +import * as readerLocalActionExportAnnotationSet from "./exportAnnotationSet"; export { + readerLocalActionExportAnnotationSet, + readerLocalActionSetResourceToCache, readerLocalActionAnnotations, readerLocalActionSetConfig, readerLocalActionSetTransientConfig, diff --git a/src/renderer/reader/redux/actions/search/cache.ts b/src/renderer/reader/redux/actions/resourceCache.ts similarity index 66% rename from src/renderer/reader/redux/actions/search/cache.ts rename to src/renderer/reader/redux/actions/resourceCache.ts index 37da70d83..0fa4487b2 100644 --- a/src/renderer/reader/redux/actions/search/cache.ts +++ b/src/renderer/reader/redux/actions/resourceCache.ts @@ -6,23 +6,22 @@ // ==LICENSE-END== import { Action } from "readium-desktop/common/models/redux"; -import { ISearchDocument } from "readium-desktop/utils/search/search.interface"; +import { ICacheDocument } from "readium-desktop/common/redux/states/renderer/resourceCache"; -import { ISearchState } from "readium-desktop/common/redux/states/renderer/search"; - -export const ID = "READER_SEARCH_SET_CACHE"; +export const ID = "READER_RESOURCE_SET_CACHE"; // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface Payload extends Partial { +export interface Payload { + searchDocument: ICacheDocument[]; } -export function build(...data: ISearchDocument[]): +export function build(data: ICacheDocument[]): Action { return { type: ID, payload: { - cacheArray: data, + searchDocument: data, }, }; } diff --git a/src/renderer/reader/redux/actions/search/index.ts b/src/renderer/reader/redux/actions/search/index.ts index 275ff874f..d1edbc488 100644 --- a/src/renderer/reader/redux/actions/search/index.ts +++ b/src/renderer/reader/redux/actions/search/index.ts @@ -5,7 +5,6 @@ // that can be found in the LICENSE file exposed on Github (readium) in the project repository. // ==LICENSE-END== -import * as setCache from "./cache"; import * as cancel from "./cancel"; import * as enable from "./enable"; import * as focus from "./focus"; @@ -15,7 +14,6 @@ import * as previous from "./previous"; import * as request from "./request"; export { - setCache, request, cancel, next, diff --git a/src/renderer/reader/redux/middleware/sync.ts b/src/renderer/reader/redux/middleware/sync.ts index 3090f84c2..4fdc70cc5 100644 --- a/src/renderer/reader/redux/middleware/sync.ts +++ b/src/renderer/reader/redux/middleware/sync.ts @@ -53,6 +53,8 @@ const SYNCHRONIZABLE_ACTIONS: string[] = [ annotationActions.importConfirmOrAbort.ID, creatorActions.set.ID, + + annotationActions.shiftFromAnnotationImportQueue.ID, ]; export const reduxSyncMiddleware = syncFactory(SYNCHRONIZABLE_ACTIONS); diff --git a/src/renderer/reader/redux/reducers/index.ts b/src/renderer/reader/redux/reducers/index.ts index 3e0f1985d..d47d723de 100644 --- a/src/renderer/reader/redux/reducers/index.ts +++ b/src/renderer/reader/redux/reducers/index.ts @@ -35,9 +35,9 @@ import { sessionReducer } from "readium-desktop/common/redux/reducers/session"; import { readerDefaultConfigReducer } from "readium-desktop/common/redux/reducers/reader/defaultConfig"; import { themeReducer } from "readium-desktop/common/redux/reducers/theme"; import { versionUpdateReducer } from "readium-desktop/common/redux/reducers/version-update"; -import { IAnnotationState } from "readium-desktop/common/redux/states/renderer/annotation"; +import { IAnnotationPreParsingState, IAnnotationState } from "readium-desktop/common/redux/states/renderer/annotation"; import { annotationModeEnableReducer } from "./annotationModeEnable"; -import { readerActions } from "readium-desktop/common/redux/actions"; +import { annotationActions, readerActions } from "readium-desktop/common/redux/actions"; import { readerMediaOverlayReducer } from "./mediaOverlay"; import { readerTTSReducer } from "./tts"; import { readerTransientConfigReducer } from "./readerTransientConfig"; @@ -46,6 +46,8 @@ import { annotationTagsIndexReducer } from "./annotationTagsIndex"; import { creatorReducer } from "readium-desktop/common/redux/reducers/creator"; import { importAnnotationReducer } from "readium-desktop/renderer/common/redux/reducers/importAnnotation"; import { tagReducer } from "readium-desktop/common/redux/reducers/tag"; +import { fifoReducer } from "readium-desktop/utils/redux-reducers/fifo.reducer"; +import { readerResourceCacheReducer } from "./resourceCache"; import { readerLockReducer } from "./lock"; export const rootReducer = () => { @@ -183,6 +185,7 @@ export const rootReducer = () => { lock: readerLockReducer, }), search: searchReducer, + resourceCache: readerResourceCacheReducer, annotation: annotationModeEnableReducer, annotationTagsIndex: annotationTagsIndexReducer, picker: pickerReducer, @@ -196,5 +199,20 @@ export const rootReducer = () => { publication: combineReducers({ tag: tagReducer, }), + annotationImportQueue: fifoReducer + < + annotationActions.pushToAnnotationImportQueue.TAction, + IAnnotationPreParsingState + >( + { + push: { + type: annotationActions.pushToAnnotationImportQueue.ID, + selector: (action) => action.payload.annotations, + }, + shift: { + type: annotationActions.shiftFromAnnotationImportQueue.ID, + }, + }, + ), }); }; diff --git a/src/renderer/reader/redux/reducers/resourceCache.ts b/src/renderer/reader/redux/reducers/resourceCache.ts new file mode 100644 index 000000000..778bdbe28 --- /dev/null +++ b/src/renderer/reader/redux/reducers/resourceCache.ts @@ -0,0 +1,31 @@ +// ==LICENSE-BEGIN== +// Copyright 2017 European Digital Reading Lab. All rights reserved. +// Licensed to the Readium Foundation under one or more contributor license agreements. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file exposed on Github (readium) in the project repository. +// ==LICENSE-END== + +import { ICacheDocument } from "readium-desktop/common/redux/states/renderer/resourceCache"; +import { type Reducer } from "redux"; +import { readerLocalActionSetResourceToCache } from "../actions"; + +function readerResourceCacheReducer_( + state: ICacheDocument[] = [], + action: readerLocalActionSetResourceToCache.TAction, +): ICacheDocument[] { + switch (action.type) { + case readerLocalActionSetResourceToCache.ID: + + if (action.payload.searchDocument.length === 0) { + return state; + } + const newState = state.slice(); + for (const doc of action.payload.searchDocument) { + newState.push(doc); + } + return newState; + default: + return state; + } +} +export const readerResourceCacheReducer = readerResourceCacheReducer_ as Reducer>; diff --git a/src/renderer/reader/redux/reducers/search.ts b/src/renderer/reader/redux/reducers/search.ts index 1bb06c535..f1c19176b 100644 --- a/src/renderer/reader/redux/reducers/search.ts +++ b/src/renderer/reader/redux/reducers/search.ts @@ -16,8 +16,7 @@ function searchReducer_( readerLocalActionSearch.request.TAction | readerLocalActionSearch.focus.TAction | readerLocalActionSearch.found.TAction | - readerLocalActionSearch.enable.TAction | - readerLocalActionSearch.setCache.TAction, + readerLocalActionSearch.enable.TAction, ): ISearchState { switch (action.type) { @@ -25,7 +24,6 @@ function searchReducer_( case readerLocalActionSearch.cancel.ID: case readerLocalActionSearch.enable.ID: case readerLocalActionSearch.focus.ID: - case readerLocalActionSearch.setCache.ID: case readerLocalActionSearch.found.ID: // let found = state.foundArray; @@ -36,20 +34,11 @@ function searchReducer_( // ]; // } - // let cache = state.cacheArray; - // if (action.payload.cacheArray) { - // cache = [ - // ...state.cacheArray, - // ...action.payload.cacheArray, - // ]; - // } - return { ...state, ...action.payload, // ...{ // foundArray: found, - // cacheArray: cache, // }, }; default: diff --git a/src/renderer/reader/redux/sagas/index.ts b/src/renderer/reader/redux/sagas/index.ts index 9c4581054..1f29374d0 100644 --- a/src/renderer/reader/redux/sagas/index.ts +++ b/src/renderer/reader/redux/sagas/index.ts @@ -19,6 +19,7 @@ import * as ipc from "./ipc"; import * as search from "./search"; import * as winInit from "./win"; import * as annotation from "./annotation"; +import * as shareAnnotationSet from "./shareAnnotationSet"; import { takeSpawnEvery, takeSpawnEveryChannel } from "readium-desktop/common/redux/sagas/takeSpawnEvery"; import { setTheme } from "readium-desktop/common/redux/actions/theme"; import { MediaOverlaysStateEnum, TTSStateEnum, mediaOverlaysListen, ttsListen } from "@r2-navigator-js/electron/renderer"; @@ -92,6 +93,8 @@ export function* rootSaga() { search.saga(), annotation.saga(), + + shareAnnotationSet.saga(), takeSpawnEvery( setTheme.ID, diff --git a/src/renderer/reader/redux/sagas/resourceCache.ts b/src/renderer/reader/redux/sagas/resourceCache.ts new file mode 100644 index 000000000..e71b2bf58 --- /dev/null +++ b/src/renderer/reader/redux/sagas/resourceCache.ts @@ -0,0 +1,89 @@ +// ==LICENSE-BEGIN== +// Copyright 2017 European Digital Reading Lab. All rights reserved. +// Licensed to the Readium Foundation under one or more contributor license agreements. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file exposed on Github (readium) in the project repository. +// ==LICENSE-END== + +import { IReaderRootState } from "readium-desktop/common/redux/states/renderer/readerRootState"; +import { ICacheDocument } from "readium-desktop/common/redux/states/renderer/resourceCache"; +import { ContentType } from "readium-desktop/utils/contentType"; +import { + all as allTyped, select as selectTyped, + call as callTyped, put as putTyped, +} from "typed-redux-saga/macro"; +import { Publication as R2Publication } from "@r2-shared-js/models/publication"; +import { Link } from "@r2-shared-js/models/publication-link"; + +import * as debug_ from "debug"; +import { readerLocalActionSetResourceToCache } from "../actions"; +const debug = debug_("readium-desktop:renderer:reader:redux:saga:resourceCache"); + +const isFixedLayout = (link: Link, publication: R2Publication): boolean => { + if (link && link.Properties) { + if (link.Properties.Layout === "fixed") { + return true; + } + if (typeof link.Properties.Layout !== "undefined") { + return false; + } + } + + if (publication && + publication.Metadata && + publication.Metadata.Rendition) { + return publication.Metadata.Rendition.Layout === "fixed"; + } + return false; +}; + +export function* getResourceCache() { + + const cacheFromState = yield* selectTyped((state: IReaderRootState) => state.resourceCache); + + // TODO: check this before merge + if (cacheFromState && cacheFromState.length) { + debug("spine item caches already acquired and ready ! len:", cacheFromState.length); + return; + } + // + + const r2Manifest = yield* selectTyped((state: IReaderRootState) => state.reader.info.r2Publication); + const manifestUrlR2Protocol = yield* selectTyped( + (state: IReaderRootState) => state.reader.info.manifestUrlR2Protocol, + ); + const request = r2Manifest.Spine.map((ln) => callTyped(async () => { + const ret: ICacheDocument = { + xml: "", // initialized in code below + href: ln.Href, + contentType: ln.TypeLink ? ln.TypeLink : ContentType.Xhtml, + isFixedLayout: isFixedLayout(ln, r2Manifest), + }; + debug(`requestPublicationData ISearchDocument: [${JSON.stringify(ret, null, 4)}]`); + + try { + // DEPRECATED API (watch for the inverse function parameter order!): + // url.resolve(manifestUrlR2Protocol, ln.Href) + const url = new URL(ln.Href, manifestUrlR2Protocol); + if (url.pathname.endsWith(".html") || url.pathname.endsWith(".xhtml") || url.pathname.endsWith(".xml") + || ln.TypeLink === ContentType.Xhtml + || ln.TypeLink === ContentType.Html + || ln.TypeLink === ContentType.Xml) { + + const urlStr = url.toString(); + const res = await fetch(urlStr); + if (res.ok) { + const text = await res.text(); + ret.xml = text; + } + } + } catch (e) { + console.error("requestPublicationData", ln.Href, e); + } + + return ret; + })); + + const result = yield* allTyped(request); + yield* putTyped(readerLocalActionSetResourceToCache.build(result)); +} diff --git a/src/renderer/reader/redux/sagas/search.ts b/src/renderer/reader/redux/sagas/search.ts index 295486b7c..cebf5f7e8 100644 --- a/src/renderer/reader/redux/sagas/search.ts +++ b/src/renderer/reader/redux/sagas/search.ts @@ -10,9 +10,7 @@ import * as debug_ from "debug"; import { clone, flatten } from "ramda"; import { takeSpawnEvery } from "readium-desktop/common/redux/sagas/takeSpawnEvery"; import { IReaderRootState } from "readium-desktop/common/redux/states/renderer/readerRootState"; -import { ContentType } from "readium-desktop/utils/contentType"; -import { search } from "readium-desktop/utils/search/search"; -import { ISearchDocument, ISearchResult } from "readium-desktop/utils/search/search.interface"; +import { ISearchResult, search } from "readium-desktop/utils/search/search"; // eslint-disable-next-line local-rules/typed-redux-saga-use-typed-effects import { all, call, cancel, join, put, take } from "redux-saga/effects"; import { @@ -23,13 +21,13 @@ import { import { IRangeInfo } from "@r2-navigator-js/electron/common/selection"; import { handleLinkLocator } from "@r2-navigator-js/electron/renderer"; import { Locator as R2Locator } from "@r2-navigator-js/electron/common/locator"; -import { Publication as R2Publication } from "@r2-shared-js/models/publication"; -import { Link } from "@r2-shared-js/models/publication-link"; + import { readerLocalActionHighlights, readerLocalActionSearch } from "../actions"; import { IHighlightHandlerState } from "readium-desktop/common/redux/states/renderer/highlight"; import debounce from "debounce"; +import { getResourceCache } from "./resourceCache"; const handleLinkLocatorDebounced = debounce(handleLinkLocator, 200); @@ -68,7 +66,7 @@ function* searchRequest(action: readerLocalActionSearch.request.TAction) { yield call(clearSearch); const text = action.payload.textSearch; - const cacheFromState = yield* selectTyped((state: IReaderRootState) => state.search.cacheArray); + const cacheFromState = yield* selectTyped((state: IReaderRootState) => state.resourceCache); const searchMap = cacheFromState.map( (v) => @@ -183,69 +181,11 @@ function* searchFocus(action: readerLocalActionSearch.focus.TAction) { } } -const isFixedLayout = (link: Link, publication: R2Publication): boolean => { - if (link && link.Properties) { - if (link.Properties.Layout === "fixed") { - return true; - } - if (typeof link.Properties.Layout !== "undefined") { - return false; - } - } - - if (publication && - publication.Metadata && - publication.Metadata.Rendition) { - return publication.Metadata.Rendition.Layout === "fixed"; - } - return false; -}; -function* requestPublicationData() { - - const r2Manifest = yield* selectTyped((state: IReaderRootState) => state.reader.info.r2Publication); - const manifestUrlR2Protocol = yield* selectTyped( - (state: IReaderRootState) => state.reader.info.manifestUrlR2Protocol, - ); - const request = r2Manifest.Spine.map((ln) => call(async () => { - const ret: ISearchDocument = { - xml: "", // initialized in code below - href: ln.Href, - contentType: ln.TypeLink ? ln.TypeLink : ContentType.Xhtml, - isFixedLayout: isFixedLayout(ln, r2Manifest), - }; - debug(`requestPublicationData ISearchDocument: [${JSON.stringify(ret, null, 4)}]`); - - try { - // DEPRECATED API (watch for the inverse function parameter order!): - // url.resolve(manifestUrlR2Protocol, ln.Href) - const url = new URL(ln.Href, manifestUrlR2Protocol); - if (url.pathname.endsWith(".html") || url.pathname.endsWith(".xhtml") || url.pathname.endsWith(".xml") - || ln.TypeLink === ContentType.Xhtml - || ln.TypeLink === ContentType.Html - || ln.TypeLink === ContentType.Xml) { - - const urlStr = url.toString(); - const res = await fetch(urlStr); - if (res.ok) { - const text = await res.text(); - ret.xml = text; - } - } - } catch (e) { - console.error("requestPublicationData", ln.Href, e); - } - - return ret; - })); - - const result = yield* allTyped(request); - yield put(readerLocalActionSearch.setCache.build(...result)); -} function* searchEnable(_action: readerLocalActionSearch.enable.TAction) { - const taskRequest = yield* forkTyped(requestPublicationData); + const taskRequest = yield* forkTyped(getResourceCache); const taskSearch = yield* takeLatestTyped(readerLocalActionSearch.request.ID, function*(action: readerLocalActionSearch.request.TAction) { diff --git a/src/renderer/reader/redux/sagas/shareAnnotationSet.ts b/src/renderer/reader/redux/sagas/shareAnnotationSet.ts new file mode 100644 index 000000000..e818784c4 --- /dev/null +++ b/src/renderer/reader/redux/sagas/shareAnnotationSet.ts @@ -0,0 +1,164 @@ +// ==LICENSE-BEGIN== +// Copyright 2017 European Digital Reading Lab. All rights reserved. +// Licensed to the Readium Foundation under one or more contributor license agreements. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file exposed on Github (readium) in the project repository. +// ==LICENSE-END= + +import * as debug_ from "debug"; +import { select as selectTyped, take as takeTyped, all as allTyped, call as callTyped, SagaGenerator, put as putTyped, delay as delayTyped } from "typed-redux-saga/macro"; + +import { spawnLeading } from "readium-desktop/common/redux/sagas/spawnLeading"; +import { readerLocalActionExportAnnotationSet } from "../actions"; +// import { delay } from "redux-saga/effects"; +import { getResourceCache } from "./resourceCache"; +import { ICacheDocument } from "readium-desktop/common/redux/states/renderer/resourceCache"; +import { IReaderRootState } from "readium-desktop/common/redux/states/renderer/readerRootState"; +import { convertAnnotationStateArrayToReadiumAnnotationSet, convertSelectorTargetToLocatorExtended, IAnnotationStateWithICacheDocument } from "readium-desktop/common/readium/annotation/converter"; +import { IReadiumAnnotationSet } from "readium-desktop/common/readium/annotation/annotationModel.type"; +import { annotationActions, readerActions } from "readium-desktop/common/redux/actions"; +import { IAnnotationState } from "readium-desktop/common/redux/states/renderer/annotation"; + +// Logger +const debug = debug_("readium-desktop:renderer:reader:redux:sagas:shareAnnotationSet"); +debug("_"); + + + +const getCacheDocumentFromLocator = (cacheDocumentArray: ICacheDocument[], hrefSource: string): ICacheDocument => { + + for (const cacheDoc of cacheDocumentArray) { + if (hrefSource && cacheDoc.href && cacheDoc.href === hrefSource) { + return cacheDoc; + } + } + + return undefined; +}; + +export function* importAnnotationSet(): SagaGenerator { + + debug("importAnnotationSet just started !"); + yield* callTyped(getResourceCache); + + let importQueue = yield* selectTyped((state: IReaderRootState) => state.annotationImportQueue); + debug("ImportAnnotationQueue length", importQueue.length); + while (importQueue.length) { + + // start import routine + const { target, ...annotationState } = importQueue[0]; + + // not atomic : if the reader is closing during this import process it can forget data + yield* putTyped(annotationActions.shiftFromAnnotationImportQueue.build()); + + debug("annotationState:", JSON.stringify(annotationState, null, 4)); + debug("SelectorTarget from AnnotationState", JSON.stringify(target, null, 4)); + + + const { source } = target; + const cacheDocuments = yield* selectTyped((state: IReaderRootState) => state.resourceCache); + const cacheDoc = getCacheDocumentFromLocator(cacheDocuments, source); + + const annotationStateFormated: IAnnotationState = { + ...annotationState, + locatorExtended: yield* callTyped(() => convertSelectorTargetToLocatorExtended(target, cacheDoc)), + }; + if (!annotationStateFormated.locatorExtended) { + debug("ERROR: no locator found !! for annotationState, doesn't import this note"); + continue; + } + + const annotationsList = yield* selectTyped((state: IReaderRootState) => state.reader.annotation); + + const found = annotationsList.find(([, {uuid}]) => annotationStateFormated.uuid === uuid); + if (found) { + const foundAnno = found[1]; + yield* putTyped(readerActions.annotation.update.build(foundAnno, annotationStateFormated)); + } else { + // push new annotation to reader and then sync it with main db process + yield* putTyped(readerActions.annotation.push.build(annotationStateFormated)); + } + + // wait 100ms to not overload event-loop + yield* delayTyped(100); + + // reload import queue for the next shift phase + importQueue = yield* selectTyped((state: IReaderRootState) => state.annotationImportQueue); + } + + debug("Wait for any annotation in import queue"); + yield* takeTyped(annotationActions.pushToAnnotationImportQueue.build); + debug("New annotation put in queue from import annotation routine. Start the import routine"); +} + +function* exportAnnotationSet(): SagaGenerator { + + const exportAnnotationSetAction = yield* takeTyped(readerLocalActionExportAnnotationSet.build); + const { payload: { annotationArray, publicationView, label } } = exportAnnotationSetAction; + + yield* callTyped(getResourceCache); + + debug("exportAnnotationSet just started !"); + debug("AnnotationArray: ", annotationArray); + debug("PubView ok?", typeof publicationView); + debug("label:", label); + + const cacheDocuments = yield* selectTyped((state: IReaderRootState) => state.resourceCache); + + const annotationsWithCacheDocumentArray: IAnnotationStateWithICacheDocument[] = []; + + for (const anno of annotationArray) { + annotationsWithCacheDocumentArray.push({ + ...anno, + __cacheDocument: getCacheDocumentFromLocator(cacheDocuments, anno.locatorExtended?.locator?.href), + }); + } + + const readiumAnnotationSet = yield* callTyped(() => convertAnnotationStateArrayToReadiumAnnotationSet(annotationsWithCacheDocumentArray, publicationView, label)); + + debug("readiumAnnotationSet generated, prepare to download it"); + + const downloadAnnotationJSON = (contents: IReadiumAnnotationSet, filename: string) => { + + const data = JSON.stringify(contents, null, 2); + const blob = new Blob([data], { type: "application/rd-annotations+json" }); + const jsonObjectUrl = URL.createObjectURL(blob); + const anchorEl = document.createElement("a"); + anchorEl.href = jsonObjectUrl; + anchorEl.download = `${filename}.annotation`; + anchorEl.click(); + URL.revokeObjectURL(jsonObjectUrl); + }; + + downloadAnnotationJSON(readiumAnnotationSet, label); + +} + +export const saga = () => + allTyped([ + spawnLeading( + exportAnnotationSet, + (e) => console.error("exportAnnotationSet", e), + ), + spawnLeading( + function* () { + + let gotTheLock = yield* selectTyped((state: IReaderRootState) => state.reader.lock); + if (!gotTheLock) { + yield* takeTyped(readerActions.setTheLock.build); + } + + gotTheLock = yield* selectTyped((state: IReaderRootState) => state.reader.lock); + if (!gotTheLock) { + throw new Error("unreachable!!!"); + } + + while (true) { + yield* callTyped(importAnnotationSet); + } + + }, + (e) => console.error("importAnnotationSet", e), + ), + ]); + diff --git a/src/resources/locales/ar.json b/src/resources/locales/ar.json index a683abc10..dc6d38718 100644 --- a/src/resources/locales/ar.json +++ b/src/resources/locales/ar.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "إلغاء الأمر", diff --git a/src/resources/locales/bg.json b/src/resources/locales/bg.json index 3baed276d..9d0c168ea 100644 --- a/src/resources/locales/bg.json +++ b/src/resources/locales/bg.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Отказ", diff --git a/src/resources/locales/ca.json b/src/resources/locales/ca.json index 67b0715fb..dedeeae9f 100644 --- a/src/resources/locales/ca.json +++ b/src/resources/locales/ca.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Cancel·lar", diff --git a/src/resources/locales/de.json b/src/resources/locales/de.json index 95b2202f4..b60daf397 100644 --- a/src/resources/locales/de.json +++ b/src/resources/locales/de.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Abbrechen", diff --git a/src/resources/locales/el.json b/src/resources/locales/el.json index b1455669e..70efeec41 100644 --- a/src/resources/locales/el.json +++ b/src/resources/locales/el.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Ακύρωση", diff --git a/src/resources/locales/en.json b/src/resources/locales/en.json index 1de70ba8f..7dc1eee4c 100644 --- a/src/resources/locales/en.json +++ b/src/resources/locales/en.json @@ -71,7 +71,7 @@ "lastAdditions": "Recently added" }, "export": "Save as", - "exportAnnotation": "Save notes as", + "exportAnnotation": "Export notes...", "format": "Format", "importAnnotation": "Import notes", "lang": "Language", @@ -120,18 +120,20 @@ }, "dialog": { "annotations": { - "descAuthor": "of {{- author}}", - "descList": "{{- nb}} note(s) from {{- creator}} will be associated with {{- title}} {{- author}}", - "descNewer": "{{- nb}} newer version(s) of these notes are already associated with the publication.", - "descOlder": "{{- nb}} older version(s) of these notes are already associated with the publication.", - "descTitle": "Title of the set: ", + "descAuthor": "by '{{- author}}'", + "descCreator": "created by", + "descList": "{{- nb}} note(s) {{- creator}} will be imported into '{{- title}}' {{- author}}", + "descNewer": "Conflict(s): {{- nb}} newer note(s)", + "descOlder": "Conflict(s): {{- nb}} older notes(s)", + "descTitle": "Annotations title: ", "importAll": "Import all notes", - "importWithoutConflict": "Import notes without conflict", - "title": "Do you want to import these notes?" + "importWithoutConflict": "Import only conflict-free notes", + "origin": "Origin publication: '{{- title}}' {{- author}}", + "title": "Import notes?" }, "cancel": "Cancel", - "deleteAnnotations": "Delete note?", - "deleteAnnotationsText": "Do you want to delete {{- annotationListLength}} note(s)?", + "deleteAnnotations": "Delete annotations?", + "deleteAnnotationsText": "Are you sure you want to delete {{- annotationListLength}} note(s)?", "deleteFeed": "Delete catalog?", "deletePublication": "Delete publication?", "import": "Confirm import:", @@ -178,12 +180,12 @@ }, "message": { "annotations": { - "alreadyImported": "All notes already imported, aborting the import", - "emptyFile": "No note available in the file", + "alreadyImported": "All notes are already imported.", + "emptyFile": "No notes available in the file.", "errorParsing": "Error during file parsing: ", - "noBelongTo": "Unable to import notes, at least one note does not belong to the publication", + "noBelongTo": "Unable to import notes, at least one note does not belong to the publication.", "nothing": "There is no note ready to be imported, aborting the import", - "success": "Import done" + "success": "Import completed successfully." }, "download": { "error": "Downloading [{{- title}}] failed: [{{- err}}]" @@ -325,10 +327,10 @@ "addNote": "Annotate", "advancedMode": "Instant mode (auto create after select)", "annotationsExport": { - "description": "Name this set of annotations", + "description": "Name the exported notes:", "title": "Title" }, - "annotationsOptions": "Notes Options", + "annotationsOptions": "Annotation Options", "colors": { "bluegreen": "Blue-green", "cyan": "Cyan", @@ -339,12 +341,12 @@ "red": "Red", "yellow": "Yellow" }, - "export": "Save notes as", + "export": "Save notes as...", "filter": { "all": "All", "filterByColor": "Filter by Color", "filterByCreator": "Filter by Creator", - "filterByDrawtype": "Filter by Drawtype", + "filterByDrawtype": "Filter by Type", "filterByTag": "Filter by Tag", "filterOptions": "Filter Options", "none": "None" @@ -578,7 +580,7 @@ "next": "Next" }, "description": { - "annotations": "Highlight document text, choose style and color, attach personal notes. Create annotations in 'quick' mode to bypass the editor (you can enter text and customize styles later). For extra speed, use the 'instant' mode which creates annotations immediately after highlighting document text!", + "annotations": "Highlight document text, choose style and color, attach personal notes. Create annotations in 'quick' mode to bypass the editor (you can enter text and customize styles later). For extra speed, use the 'instant' mode which creates notes immediately after highlighting document text!", "catalogs": "Borrow publications from your local library or discover publications via OPDS feeds. Access a variety of publications from multiple sources thanks to online catalogs.", "home": "Thorium looks different! Let's discover what's new...", "readingView1": "Customize your reading and listening preferences: Thorium now offers multiple color themes for both the user interface and publication documents. It is now also easier to configure 'readaloud' ebooks with synthetic speech and synchronized text/audio narration.", diff --git a/src/resources/locales/es.json b/src/resources/locales/es.json index 35013aa2c..7851f48f9 100644 --- a/src/resources/locales/es.json +++ b/src/resources/locales/es.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Cancelar", diff --git a/src/resources/locales/eu.json b/src/resources/locales/eu.json index f181bc3ea..d0ce144ff 100644 --- a/src/resources/locales/eu.json +++ b/src/resources/locales/eu.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Ezeztatu", diff --git a/src/resources/locales/fi.json b/src/resources/locales/fi.json index 182e09b5c..2fde0fb09 100644 --- a/src/resources/locales/fi.json +++ b/src/resources/locales/fi.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Peru", diff --git a/src/resources/locales/fr.json b/src/resources/locales/fr.json index 38f9b5553..b3276d4bd 100644 --- a/src/resources/locales/fr.json +++ b/src/resources/locales/fr.json @@ -120,14 +120,16 @@ }, "dialog": { "annotations": { - "descAuthor": "de {{- author}}", - "descList": "{{- nb}} note(s) de {{- creator}} sera associé avec {{- title}} {{- author}}", - "descNewer": "{{- nb}} nouvelle version(s) de ces notes sont déjà associées à cette publication.", - "descOlder": "{{- nb}} ancienne version(s) de ces notes sont déjà associées à cette publication.", - "descTitle": "Titre de la liste : ", - "importAll": "Importer toute les notes", - "importWithoutConflict": "Importer les notes sans conflit", - "title": "Voulez-vous importer ces notes ?" + "descAuthor": "par {{- author}}", + "descCreator": "créées par", + "descList": "{{- nb}} note(s) {{- creator}} seront importées vers {{- title}} {{- author}}", + "descNewer": "{{- nb}} conflits: certaines notes associées à la publication sont plus récentes", + "descOlder": "{{- nb}} conflits: certaines notes associées à la publication sont plus anciennes", + "descTitle": "Titre de la liste de notes : ", + "importAll": "Importer toutes les notes", + "importWithoutConflict": "Importer les notes sans conflits", + "origin": "Source : {{- title}} {{- author}}", + "title": "Voulez-vous importer toutes ces notes ?" }, "cancel": "Annuler", "deleteAnnotations": "Supprimer la note", diff --git a/src/resources/locales/gl.json b/src/resources/locales/gl.json index da04715d7..5f42e9ab0 100644 --- a/src/resources/locales/gl.json +++ b/src/resources/locales/gl.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Cancelar", diff --git a/src/resources/locales/hr.json b/src/resources/locales/hr.json index fabbe9cca..63c412ea9 100644 --- a/src/resources/locales/hr.json +++ b/src/resources/locales/hr.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Poništi", diff --git a/src/resources/locales/it.json b/src/resources/locales/it.json index 3cd71bacd..6a6b4d815 100644 --- a/src/resources/locales/it.json +++ b/src/resources/locales/it.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Annulla", diff --git a/src/resources/locales/ja.json b/src/resources/locales/ja.json index 868e2f126..5438fe530 100644 --- a/src/resources/locales/ja.json +++ b/src/resources/locales/ja.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "キャンセル", diff --git a/src/resources/locales/ka.json b/src/resources/locales/ka.json index 660c4ba83..18c1f4b6a 100644 --- a/src/resources/locales/ka.json +++ b/src/resources/locales/ka.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "გადაფიქრება", diff --git a/src/resources/locales/ko.json b/src/resources/locales/ko.json index 8cea3d823..e7524f808 100644 --- a/src/resources/locales/ko.json +++ b/src/resources/locales/ko.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "취소", diff --git a/src/resources/locales/lt.json b/src/resources/locales/lt.json index 7f885d78b..0116e85a2 100644 --- a/src/resources/locales/lt.json +++ b/src/resources/locales/lt.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "{{- author}}", + "descCreator": "", "descList": "{{- nb}} pastaba(-os, -ų), kurias sukūrė {{- creator}}, bus susietos su {{- author}} leidiniu {{- title}}", "descNewer": "{{- nb}} naujesnė(-s, -ių) šių pastabų versija(-os, -ų) jau susieta(-os) su leidiniu.", "descOlder": "{{- nb}} senesnė(-s, -ių) šių pastabų versija(-os, -ų) jau susieta(-os) su leidiniu.", "descTitle": "Pastabų rinkinio pavadinimas:", "importAll": "Importuoti visas pastabas", "importWithoutConflict": "Importuoti pastabas, neturinčias konfliktų", + "origin": "", "title": "Ar norite importuoti šias pastabas?" }, "cancel": "Nutraukti", diff --git a/src/resources/locales/nl.json b/src/resources/locales/nl.json index 44895643e..27985d5cd 100644 --- a/src/resources/locales/nl.json +++ b/src/resources/locales/nl.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Annuleer", diff --git a/src/resources/locales/pt-br.json b/src/resources/locales/pt-br.json index 16595e5f6..433b9337c 100644 --- a/src/resources/locales/pt-br.json +++ b/src/resources/locales/pt-br.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Cancelar", diff --git a/src/resources/locales/pt-pt.json b/src/resources/locales/pt-pt.json index a2cb64049..6b2181467 100644 --- a/src/resources/locales/pt-pt.json +++ b/src/resources/locales/pt-pt.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "de {{- author}}", + "descCreator": "", "descList": "{{- nb}} anotações de {{- creator}} serão associadas a {{- title}} {{- author}}", "descNewer": "{{- nb}} versões mais recentes destas anotações já estão associadas à publicação.", "descOlder": "{{- nb}} versões mais antigas destas anotações já estão associadas à publicação.", "descTitle": "Título do conjunto de anotações:", "importAll": "Importar todas as anotações", "importWithoutConflict": "Importar anotações sem conflitos", + "origin": "", "title": "Quer importar estas anotações?" }, "cancel": "Cancelar", diff --git a/src/resources/locales/ru.json b/src/resources/locales/ru.json index 38f64c163..0d7a240fc 100644 --- a/src/resources/locales/ru.json +++ b/src/resources/locales/ru.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Отменить", diff --git a/src/resources/locales/sl.json b/src/resources/locales/sl.json index 808688173..cfb758dea 100644 --- a/src/resources/locales/sl.json +++ b/src/resources/locales/sl.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "Prekliči", diff --git a/src/resources/locales/sv.json b/src/resources/locales/sv.json index 91f30a9cf..efa361592 100644 --- a/src/resources/locales/sv.json +++ b/src/resources/locales/sv.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "av {{- author}}", + "descCreator": "", "descList": "{{- nb}} anteckning(ar) av {{- creator}} kommer att kopplas till {{- title}} {{- author}}", "descNewer": "{{- nb}} nyare versioner av de här anteckningarna är redan kopplade till publikationen.", "descOlder": "{{- nb}} äldre versioner av de här anteckningarna är redan kopplade till publikationen.", "descTitle": "Anteckningarnas titel: ", "importAll": "Importera alla anteckningar", "importWithoutConflict": "Importera anteckningar utan konflikter", + "origin": "", "title": "Vill du importera de här anteckningarna?" }, "cancel": "Avbryt", diff --git a/src/resources/locales/zh-cn.json b/src/resources/locales/zh-cn.json index 60d2cd340..18a41c4ca 100644 --- a/src/resources/locales/zh-cn.json +++ b/src/resources/locales/zh-cn.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "取消", diff --git a/src/resources/locales/zh-tw.json b/src/resources/locales/zh-tw.json index f58dbcff2..09375627b 100644 --- a/src/resources/locales/zh-tw.json +++ b/src/resources/locales/zh-tw.json @@ -121,12 +121,14 @@ "dialog": { "annotations": { "descAuthor": "", + "descCreator": "", "descList": "", "descNewer": "", "descOlder": "", "descTitle": "", "importAll": "", "importWithoutConflict": "", + "origin": "", "title": "" }, "cancel": "取消", diff --git a/src/third_party/apache-annotator/dom/css.ts b/src/third_party/apache-annotator/dom/css.ts new file mode 100644 index 000000000..90b59c71d --- /dev/null +++ b/src/third_party/apache-annotator/dom/css.ts @@ -0,0 +1,125 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { uniqueCssSelector as finder } from "@r2-navigator-js/electron/renderer/common/cssselector2-3"; + +import type { CssSelector, Matcher } from "../selector/types"; +import { ownerDocument } from "./owner-document"; +import { toRange } from "./to-range"; +import { ICssSelector } from "readium-desktop/common/readium/annotation/annotationModel.type"; + +/** + * Find the elements corresponding to the given {@link + * CssSelector}. + * + * The given CssSelector returns all elements within `scope` that it matches. + * + * The function is curried, taking first the selector and then the scope. + * + * As there may be multiple matches for a given selector, the matcher will + * return an (async) iterable that produces each match in the order they are + * found in the document. + * + * Note that the Web Annotation specification does not mention whether an + * ‘ambiguous’ CssSelector should indeed match all elements that match the + * selector value, or perhaps only the first. This implementation returns all + * matches to give users the freedom to follow either interpretation. This is + * also in line with more clearly defined behaviour of the TextQuoteSelector: + * + * > “If […] the user agent discovers multiple matching text sequences, then the + * > selection SHOULD be treated as matching all of the matches.” + * + * Note that if `scope` is *not* a Document, the [Web Annotation Data Model](https://www.w3.org/TR/2017/REC-annotation-model-20170223/#css-selector) + * leaves the behaviour undefined. This implementation will, in such a case, + * evaluate the selector relative to the document containing the scope, but only + * return those matches that are fully enclosed within the scope. There might be + * edge cases where this is not a perfect inverse of {@link describeCss}. + * + * @example + * ``` + * const matches = createCssSelectorMatcher({ + * type: 'CssSelector', + * value: '#target', + * }); + * for await (const match of matches) { + * console.log(match); + * } + * //
+ * ``` + * + * @param selector - The {@link CssSelector} to be anchored. + * @returns A {@link Matcher} function that applies `selector` to a given + * `scope`. + * + * @public + */ +export function createCssSelectorMatcher( + selector: ICssSelector, +): Matcher { + return async function* matchAll(scope) { + scope = toRange(scope); + const document = ownerDocument(scope); + for (const element of document.querySelectorAll(selector.value)) { + const range = document.createRange(); + range.selectNode(element); + + if ( + scope.isPointInRange(range.startContainer, range.startOffset) && + scope.isPointInRange(range.endContainer, range.endOffset) + ) { + yield element; + } + } + }; +} + +/** + * Returns a {@link CssSelector} that unambiguously describes the given + * element, within the given scope. + * + * @example + * ``` + * const target = document.getElementById('targetelement').firstElementChild; + * const selector = await describeCss(target); + * console.log(selector); + * // { + * // type: 'CssSelector', + * // value: '#targetelement > :nth-child(1)' + * // } + * ``` + * + * @param element - The element that the selector should describe. + * @param scope - The node that serves as the ‘document’ for purposes of finding + * an unambiguous selector. Defaults to the Document that contains `element`. + * @returns The selector unambiguously describing `element` within `scope`. + */ +export async function describeCss( + element: HTMLElement, + scope: Element = element.ownerDocument.documentElement, +): Promise { + const selector = finder(element, element.ownerDocument, { root: scope }); + return { + type: "CssSelector", + value: selector, + }; +} diff --git a/src/third_party/apache-annotator/dom/highlight-text.ts b/src/third_party/apache-annotator/dom/highlight-text.ts new file mode 100644 index 000000000..176eb8e8e --- /dev/null +++ b/src/third_party/apache-annotator/dom/highlight-text.ts @@ -0,0 +1,164 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ownerDocument } from "./owner-document"; +import { toRange } from "./to-range"; + +/** + * Wrap each text node in a given Node or Range with a `` or other + * element. + * + * If a Range is given that starts and/or ends within a Text node, that node + * will be split in order to only wrap the contained part in the mark element. + * + * The highlight can be removed again by calling the function that cleans up the + * wrapper elements. Note that this might not perfectly restore the DOM to its + * previous state: text nodes that were split are not merged again. One could + * consider running `range.commonAncestorContainer.normalize()` afterwards to + * join all adjacent text nodes. + * + * @param target - The Node/Range containing the text. If it is a Range, note + * that as highlighting modifies the DOM, the Range may be unusable afterwards. + * @param tagName - The element used to wrap text nodes. Defaults to `'mark'`. + * @param attributes - An object defining any attributes to be set on the + * wrapper elements, e.g. its `class`. + * @returns A function that removes the created highlight. + * + * @public + */ +export function highlightText( + target: Node | Range, + tagName = "mark", + attributes: Record = {}, +): () => void { + // First put all nodes in an array (splits start and end nodes if needed) + const nodes = textNodesInRange(toRange(target)); + + // Highlight each node + const highlightElements: HTMLElement[] = []; + for (const node of nodes) { + const highlightElement = wrapNodeInHighlight(node, tagName, attributes); + highlightElements.push(highlightElement); + } + + // Return a function that cleans up the highlightElements. + function removeHighlights() { + // Remove each of the created highlightElements. + for (const highlightElement of highlightElements) { + removeHighlight(highlightElement); + } + } + return removeHighlights; +} + +// Return an array of the text nodes in the range. Split the start and end nodes if required. +function textNodesInRange(range: Range): Text[] { + // If the start or end node is a text node and only partly in the range, split it. + if (isTextNode(range.startContainer) && range.startOffset > 0) { + const endOffset = range.endOffset; // (this may get lost when the splitting the node) + const createdNode = range.startContainer.splitText(range.startOffset); + if (range.endContainer === range.startContainer) { + // If the end was in the same container, it will now be in the newly created node. + range.setEnd(createdNode, endOffset - range.startOffset); + } + range.setStart(createdNode, 0); + } + if ( + isTextNode(range.endContainer) && + range.endOffset < range.endContainer.length + ) { + range.endContainer.splitText(range.endOffset); + } + + // Collect the text nodes. + const walker = ownerDocument(range).createTreeWalker( + range.commonAncestorContainer, + NodeFilter.SHOW_TEXT, + { + acceptNode: (node) => + range.intersectsNode(node) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT, + }, + ); + walker.currentNode = range.startContainer; + + // // Optimise by skipping nodes that are explicitly outside the range. + // const NodeTypesWithCharacterOffset = [ + // Node.TEXT_NODE, + // Node.PROCESSING_INSTRUCTION_NODE, + // Node.COMMENT_NODE, + // ]; + // if (!NodeTypesWithCharacterOffset.includes(range.startContainer.nodeType)) { + // if (range.startOffset < range.startContainer.childNodes.length) { + // walker.currentNode = range.startContainer.childNodes[range.startOffset]; + // } else { + // walker.nextSibling(); // TODO verify this is correct. + // } + // } + + const nodes: Text[] = []; + if (isTextNode(walker.currentNode)) nodes.push(walker.currentNode); + while (walker.nextNode() && range.comparePoint(walker.currentNode, 0) !== 1) + nodes.push(walker.currentNode as Text); + return nodes; +} + +// Replace [node] with [node] +function wrapNodeInHighlight( + node: ChildNode, + tagName: string, + attributes: Record, +): HTMLElement { + const document = node.ownerDocument as Document; + const highlightElement = document.createElement(tagName); + Object.keys(attributes).forEach((key) => { + highlightElement.setAttribute(key, attributes[key]); + }); + const tempRange = document.createRange(); + tempRange.selectNode(node); + tempRange.surroundContents(highlightElement); + return highlightElement; +} + +// Remove a highlight element created with wrapNodeInHighlight. +function removeHighlight(highlightElement: HTMLElement) { + // If it has somehow been removed already, there is nothing to be done. + if (!highlightElement.parentNode) return; + if (highlightElement.childNodes.length === 1) { + highlightElement.replaceWith(highlightElement.firstChild as Node); + } else { + // If the highlight somehow contains multiple nodes now, move them all. + while (highlightElement.firstChild) { + highlightElement.parentNode.insertBefore( + highlightElement.firstChild, + highlightElement, + ); + } + highlightElement.remove(); + } +} + +function isTextNode(node: Node): node is Text { + return node.nodeType === Node.TEXT_NODE; +} diff --git a/src/third_party/apache-annotator/dom/index.ts b/src/third_party/apache-annotator/dom/index.ts new file mode 100644 index 000000000..7dcf3a948 --- /dev/null +++ b/src/third_party/apache-annotator/dom/index.ts @@ -0,0 +1,28 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from "./css"; +export * from "./range/index"; +export * from "./text-quote/index"; +export * from "./text-position/index"; +export * from "./highlight-text"; diff --git a/src/third_party/apache-annotator/dom/normalize-range.ts b/src/third_party/apache-annotator/dom/normalize-range.ts new file mode 100644 index 000000000..4d5dfcaa8 --- /dev/null +++ b/src/third_party/apache-annotator/dom/normalize-range.ts @@ -0,0 +1,168 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ownerDocument } from "./owner-document"; + +/** + * TextRange is a Range that guarantees to always have Text nodes as its start + * and end nodes. To ensure the type remains correct, it also restricts usage + * of methods that would modify these nodes (note that a user can simply cast + * the TextRange back to a Range to remove these restrictions). + */ +export interface TextRange extends Range { + readonly startContainer: Text; + readonly endContainer: Text; + cloneRange(): TextRange; + + // Allow only Text nodes to be passed to these methods. + insertNode(node: Text): void; + selectNodeContents(node: Text): void; + setEnd(node: Text, offset: number): void; + setStart(node: Text, offset: number): void; + + // Do not allow these methods to be used at all. + selectNode(node: never): void; + setEndAfter(node: never): void; + setEndBefore(node: never): void; + setStartAfter(node: never): void; + setStartBefore(node: never): void; + surroundContents(newParent: never): void; +} + +/** + * Normalise a {@link https://developer.mozilla.org/en-US/docs/Web/API/Range | + * Range} such that ranges spanning the same text become exact equals. + * + * *Note: in this context ‘text’ means any characters, including whitespace.* + + * Normalises a range such that both its start and end are text nodes, and that + * if there are equivalent text selections it takes the narrowest option (i.e. + * it prefers the start not to be at the end of a text node, and vice versa). + * + * If there is no text between the start and end, they thus collapse onto one a + * single position; and if there are multiple equivalent positions, it takes the + * first one; or, if scope is passed, the first equivalent falling within scope. + * + * Note that if the given range does not contain non-empty text nodes, it may + * end up pointing at a text node outside of it (before it if possible, else + * after). If the document does not contain any text nodes, an error is thrown. + */ +export function normalizeRange(range: Range, scope?: Range): TextRange { + const document = ownerDocument(range); + const walker = document.createTreeWalker(document, NodeFilter.SHOW_TEXT, { + acceptNode(node: Text) { + return !scope || scope.intersectsNode(node) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }); + + let [startContainer, startOffset] = snapBoundaryPointToTextNode( + range.startContainer, + range.startOffset, + ); + + // If we point at the end of a text node, move to the start of the next one. + // The step is repeated to skip over empty text nodes. + walker.currentNode = startContainer; + while (startOffset === startContainer.length && walker.nextNode()) { + startContainer = walker.currentNode as Text; + startOffset = 0; + } + + // Set the range’s start; note this might move its end too. + range.setStart(startContainer, startOffset); + + let [endContainer, endOffset] = snapBoundaryPointToTextNode( + range.endContainer, + range.endOffset, + ); + + // If we point at the start of a text node, move to the end of the previous one. + // The step is repeated to skip over empty text nodes. + walker.currentNode = endContainer; + while (endOffset === 0 && walker.previousNode()) { + endContainer = walker.currentNode as Text; + endOffset = endContainer.length; + } + + // Set the range’s end; note this might move its start too. + range.setEnd(endContainer, endOffset); + + return range as TextRange; +} + +// Given an arbitrary boundary point, this returns either: +// - that same boundary point, if its node is a text node; +// - otherwise the first boundary point after it whose node is a text node, if any; +// - otherwise, the last boundary point before it whose node is a text node. +// If the document has no text nodes, it throws an error. +function snapBoundaryPointToTextNode( + node: Node, + offset: number, +): [Text, number] { + if (isText(node)) return [node, offset]; + + // Find the node at or right after the boundary point. + let curNode: Node; + if (isCharacterData(node)) { + curNode = node; + } else if (offset < node.childNodes.length) { + curNode = node.childNodes[offset]; + } else { + curNode = node; + while (curNode.nextSibling === null) { + if (curNode.parentNode === null) + // Boundary point is at end of document + throw new Error("not implemented"); // TODO + curNode = curNode.parentNode; + } + curNode = curNode.nextSibling; + } + + if (isText(curNode)) return [curNode, 0]; + + // Walk to the next text node, or the last if there is none. + const document = node.ownerDocument ?? (node as Document); + const walker = document.createTreeWalker(document, NodeFilter.SHOW_TEXT); + walker.currentNode = curNode; + if (walker.nextNode() !== null) { + return [walker.currentNode as Text, 0]; + } else if (walker.previousNode() !== null) { + return [walker.currentNode as Text, (walker.currentNode as Text).length]; + } else { + throw new Error("Document contains no text nodes."); + } +} + +function isText(node: Node): node is Text { + return node.nodeType === Node.TEXT_NODE; +} + +function isCharacterData(node: Node): node is CharacterData { + return ( + node.nodeType === Node.PROCESSING_INSTRUCTION_NODE || + node.nodeType === Node.COMMENT_NODE || + node.nodeType === Node.TEXT_NODE + ); +} diff --git a/src/third_party/apache-annotator/dom/owner-document.ts b/src/third_party/apache-annotator/dom/owner-document.ts new file mode 100644 index 000000000..9a2d8c698 --- /dev/null +++ b/src/third_party/apache-annotator/dom/owner-document.ts @@ -0,0 +1,37 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Get the ownerDocument for either a range or a node. + * + * @param nodeOrRange the node or range for which to get the owner document. + */ +export function ownerDocument(nodeOrRange: Node | Range): Document { + const node = isRange(nodeOrRange) ? nodeOrRange.startContainer : nodeOrRange; + // node.ownerDocument is null iff node is itself a Document. + return node.ownerDocument ?? (node as Document); +} + +function isRange(nodeOrRange: Node | Range): nodeOrRange is Range { + return "startContainer" in nodeOrRange; +} diff --git a/src/third_party/apache-annotator/dom/range/cartesian.ts b/src/third_party/apache-annotator/dom/range/cartesian.ts new file mode 100644 index 000000000..1a1fe1451 --- /dev/null +++ b/src/third_party/apache-annotator/dom/range/cartesian.ts @@ -0,0 +1,92 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Generates the Cartesian product of the sets generated by the given iterables. + * + * 𝑆₁ × ... × 𝑆ₙ = { (𝑒₁,...,𝑒ₙ) | 𝑒ᵢ ∈ 𝑆ᵢ } + */ +export async function* cartesian( + ...iterables: (Iterable | AsyncIterable)[] +): AsyncGenerator { + // Create iterators for traversing each iterable and tagging every value + // with the index of its source iterable. + const iterators = iterables.map((iterable, index) => { + const generator = async function* () { + for await (const value of iterable) { + yield { index, value }; + } + return { index }; + }; + return generator(); + }); + + try { + // Track the number of non-exhausted iterators. + let active = iterators.length; + + // Track all the values of each iterator in a log. + const logs = iterators.map(() => [] as any[]) as T[][]; + + // Track the promise of the next value of each iterator. + const nexts = iterators.map((it) => it.next()); + + // Iterate the values of all the iterators in parallel and yield tuples from + // the partial product of each new value and the existing logs of the other + // iterators. + while (active) { + // Wait for the next result. + const result = await Promise.race(nexts); + const { index } = result.value; + + // If the iterator has exhausted all the values, set the promise + // of its next value to never resolve. + if (result.done) { + active--; + nexts[index] = new Promise(() => undefined); + continue; + } + + // Append the new value to the log. + // @ts-expect-error thorium quick hack-typing + const { value } = result.value; + logs[index].push(value); + + // Record the promise of the next value. + nexts[index] = iterators[index].next(); + + // Create a scratch input for computing a partial product. + const scratch = [...logs]; + scratch[index] = [value]; + + // Synchronously compute and yield tuples of the partial product. + yield* scratch.reduce( + (acc, next) => acc.flatMap((v) => next.map((w) => [...v, w])), + [[]] as T[][], + ); + } + } finally { + const closeAll = iterators.map((it, index) => it.return({ index })); + await Promise.all(closeAll); + } +} diff --git a/src/third_party/apache-annotator/dom/range/index.ts b/src/third_party/apache-annotator/dom/range/index.ts new file mode 100644 index 000000000..9b6304344 --- /dev/null +++ b/src/third_party/apache-annotator/dom/range/index.ts @@ -0,0 +1,24 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from "./match"; diff --git a/src/third_party/apache-annotator/dom/range/match.ts b/src/third_party/apache-annotator/dom/range/match.ts new file mode 100644 index 000000000..44d1b33d6 --- /dev/null +++ b/src/third_party/apache-annotator/dom/range/match.ts @@ -0,0 +1,128 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + Matcher, + RangeSelector, + Selector, +} from "../../selector/types"; + +import { ownerDocument } from "../owner-document"; +import { toRange } from "../to-range"; +import { cartesian } from "./cartesian"; + +/** + * Find the range(s) corresponding to the given {@link RangeSelector}. + * + * As a RangeSelector itself nests two further selectors, one needs to pass a + * `createMatcher` function that will be used to process those nested selectors. + * + * The function is curried, taking first the `createMatcher` function, then the + * selector, and then the scope. + * + * As there may be multiple matches for the start & end selectors, the resulting + * matcher will return an (async) iterable, that produces a match for each + * possible pair of matches of the nested selectors (except those where its end + * would precede its start). *(Note that this behaviour is a rather free + * interpretation of the Web Annotation Data Model spec, which is silent about + * the possibility of multiple matches for RangeSelectors)* + * + * @example + * By using a matcher for {@link TextQuoteSelector}s, one + * could create a matcher for text quotes with ellipsis to select a phrase + * “ipsum … amet,”: + * ``` + * const selector = { + * type: 'RangeSelector', + * startSelector: { + * type: 'TextQuoteSelector', + * exact: 'ipsum ', + * }, + * endSelector: { + * type: 'TextQuoteSelector', + * // Because the end of a RangeSelector is *exclusive*, we will present the + * // latter part of the quote as the *prefix* so it will be part of the + * // match. + * exact: '', + * prefix: ' amet,', + * } + * }; + * const createRangeSelectorMatcher = + * makeCreateRangeSelectorMatcher(createTextQuoteMatcher); + * const match = createRangeSelectorMatcher(selector)(document.body); + * console.log(match) + * // ⇒ Range { startContainer: #text, startOffset: 6, endContainer: #text, + * // endOffset: 27, … } + * ``` + * + * @example + * To support RangeSelectors that might themselves contain RangeSelectors, + * recursion can be created by supplying the resulting matcher creator function + * as the `createMatcher` parameter: + * ``` + * const createWhicheverMatcher = (selector) => { + * const innerCreateMatcher = { + * TextQuoteSelector: createTextQuoteSelectorMatcher, + * TextPositionSelector: createTextPositionSelectorMatcher, + * RangeSelector: makeCreateRangeSelectorMatcher(createWhicheverMatcher), + * }[selector.type]; + * return innerCreateMatcher(selector); + * }); + * ``` + * + * @param createMatcher - The function used to process nested selectors. + * @returns A function that, given a RangeSelector `selector`, creates a {@link + * Matcher} function that can apply it to a given `scope`. + * + * @public + */ +export function makeCreateRangeSelectorMatcher( + createMatcher: ( + selector: T, + ) => Matcher, +): (selector: RangeSelector) => Matcher { + return function createRangeSelectorMatcher(selector) { + const startMatcher = createMatcher(selector.startSelector); + const endMatcher = createMatcher(selector.endSelector); + + return async function* matchAll(scope) { + const startMatches = startMatcher(scope); + const endMatches = endMatcher(scope); + + const pairs = cartesian(startMatches, endMatches); + + for await (let [start, end] of pairs) { + start = toRange(start); + end = toRange(end); + + const result = ownerDocument(scope).createRange(); + result.setStart(start.startContainer, start.startOffset); + // Note that a RangeSelector’s match *excludes* the endSelector’s match, + // hence we take the end’s startContainer & startOffset. + result.setEnd(end.startContainer, end.startOffset); + + if (!result.collapsed) yield result; + } + }; + }; +} diff --git a/src/third_party/apache-annotator/dom/text-node-chunker.ts b/src/third_party/apache-annotator/dom/text-node-chunker.ts new file mode 100644 index 000000000..f2d317358 --- /dev/null +++ b/src/third_party/apache-annotator/dom/text-node-chunker.ts @@ -0,0 +1,176 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Chunk, Chunker, ChunkRange } from "../selector/text/chunker"; +import { normalizeRange } from "./normalize-range"; +import { ownerDocument } from "./owner-document"; +import { toRange } from "./to-range"; + +export interface PartialTextNode extends Chunk { + readonly node: Text; + readonly startOffset: number; + readonly endOffset: number; +} + +export class EmptyScopeError extends TypeError { + constructor(message?: string) { + super(message || "Scope contains no text nodes."); + } +} + +export class OutOfScopeError extends TypeError { + constructor(message?: string) { + super( + message || + "Cannot convert node to chunk, as it falls outside of chunker’s scope.", + ); + } +} + +//@ts-expect-error thorium quick hack typing +export class TextNodeChunker implements Chunker { + private scope: Range; + private iter: NodeIterator; + + get currentChunk(): PartialTextNode { + const node = this.iter.referenceNode; + + // This test should not actually be needed, but it keeps TypeScript happy. + if (!isText(node)) throw new EmptyScopeError(); + + return this.nodeToChunk(node); + } + + nodeToChunk(node: Text): PartialTextNode { + if (!this.scope.intersectsNode(node)) throw new OutOfScopeError(); + + const startOffset = + node === this.scope.startContainer ? this.scope.startOffset : 0; + const endOffset = + node === this.scope.endContainer ? this.scope.endOffset : node.length; + + return { + node, + startOffset, + endOffset, + data: node.data.substring(startOffset, endOffset), + equals(other) { + return ( + other.node === this.node && + other.startOffset === this.startOffset && + other.endOffset === this.endOffset + ); + }, + }; + } + +//@ts-expect-error thorium quick hack typing + rangeToChunkRange(range: Range): ChunkRange { + range = range.cloneRange(); + + // Take the part of the range that falls within the scope. + if (range.compareBoundaryPoints(Range.START_TO_START, this.scope) === -1) + range.setStart(this.scope.startContainer, this.scope.startOffset); + if (range.compareBoundaryPoints(Range.END_TO_END, this.scope) === 1) + range.setEnd(this.scope.endContainer, this.scope.endOffset); + + // Ensure it starts and ends at text nodes. + const textRange = normalizeRange(range, this.scope); + + const startChunk = this.nodeToChunk(textRange.startContainer); + const startIndex = textRange.startOffset - startChunk.startOffset; + const endChunk = this.nodeToChunk(textRange.endContainer); + const endIndex = textRange.endOffset - endChunk.startOffset; + + return { startChunk, startIndex, endChunk, endIndex }; + } + +//@ts-expect-error thorium quick hack typing + chunkRangeToRange(chunkRange: ChunkRange): Range { + const range = ownerDocument(this.scope).createRange(); + // The `+…startOffset` parts are only relevant for the first chunk, as it + // might start within a text node. + range.setStart( + chunkRange.startChunk.node, + chunkRange.startIndex + chunkRange.startChunk.startOffset, + ); + range.setEnd( + chunkRange.endChunk.node, + chunkRange.endIndex + chunkRange.endChunk.startOffset, + ); + return range; + } + + /** + * @param scope A Range that overlaps with at least one text node. + */ + constructor(scope: Node | Range) { + this.scope = toRange(scope); + this.iter = ownerDocument(scope).createNodeIterator( + this.scope.commonAncestorContainer, + NodeFilter.SHOW_TEXT, + { + acceptNode: (node: Text) => { + return this.scope.intersectsNode(node) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }, + ); + + // Move the iterator to after the start (= root) node. + this.iter.nextNode(); + // If the start node is not a text node, move it to the first text node. + if (!isText(this.iter.referenceNode)) { + const nextNode = this.iter.nextNode(); + if (nextNode === null) throw new EmptyScopeError(); + } + } + + nextChunk(): PartialTextNode | null { + // Move the iterator to after the current node, so nextNode() will cause a jump. + if (this.iter.pointerBeforeReferenceNode) this.iter.nextNode(); + + if (this.iter.nextNode()) return this.currentChunk; + else return null; + } + + previousChunk(): PartialTextNode | null { + if (!this.iter.pointerBeforeReferenceNode) this.iter.previousNode(); + + if (this.iter.previousNode()) return this.currentChunk; + else return null; + } + + precedesCurrentChunk(chunk: PartialTextNode): boolean { + if (this.currentChunk === null) return false; + return !!( + this.currentChunk.node.compareDocumentPosition(chunk.node) & + Node.DOCUMENT_POSITION_PRECEDING + ); + } +} + +function isText(node: Node): node is Text { + return node.nodeType === Node.TEXT_NODE; +} diff --git a/src/third_party/apache-annotator/dom/text-position/describe.ts b/src/third_party/apache-annotator/dom/text-position/describe.ts new file mode 100644 index 000000000..33afad171 --- /dev/null +++ b/src/third_party/apache-annotator/dom/text-position/describe.ts @@ -0,0 +1,75 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describeTextPosition as abstractDescribeTextPosition } from "../../selector/text/describe-text-position"; +import { ownerDocument } from "../owner-document"; +import { TextNodeChunker } from "../text-node-chunker"; +import { toRange } from "../to-range"; +import { ITextPositionSelector } from "readium-desktop/common/readium/annotation/annotationModel.type"; + +/** + * Returns a {@link TextPositionSelector} that points at the target text within + * the given scope. + * + * When no scope is given, the position is described relative to the document + * as a whole. Note this means all the characters in all Text nodes are counted + * to determine the target’s position, including those in the `` and + * whitespace, hence even a minor modification could make the selector point to + * a different text than its original target. + * + * @example + * ``` + * const target = window.getSelection().getRangeAt(0); + * const selector = await describeTextPosition(target); + * console.log(selector); + * // { + * // type: 'TextPositionSelector', + * // start: 702, + * // end: 736 + * // } + * ``` + * + * @param range - The {@link https://developer.mozilla.org/en-US/docs/Web/API/Range + * | Range} whose text content will be described. + * @param scope - A Node or Range that serves as the ‘document’ for purposes of + * finding occurrences and determining prefix and suffix. Defaults to the full + * Document that contains `range`. + * @returns The selector describing `range` within `scope`. + * + * @public + */ +export async function describeTextPosition( + range: Range, + scope?: Node | Range, +): Promise { + scope = toRange(scope ?? ownerDocument(range)); + + const textChunks = new TextNodeChunker(scope); + if (textChunks.currentChunk === null) + throw new RangeError("Scope does not contain any Text nodes."); + + return await abstractDescribeTextPosition( + textChunks.rangeToChunkRange(range), + textChunks, + ); +} diff --git a/src/third_party/apache-annotator/dom/text-position/index.ts b/src/third_party/apache-annotator/dom/text-position/index.ts new file mode 100644 index 000000000..574545b26 --- /dev/null +++ b/src/third_party/apache-annotator/dom/text-position/index.ts @@ -0,0 +1,25 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from "./describe"; +export * from "./match"; diff --git a/src/third_party/apache-annotator/dom/text-position/match.ts b/src/third_party/apache-annotator/dom/text-position/match.ts new file mode 100644 index 000000000..80c6b9866 --- /dev/null +++ b/src/third_party/apache-annotator/dom/text-position/match.ts @@ -0,0 +1,72 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Matcher } from "../../selector/types"; +import { textPositionSelectorMatcher as abstractTextPositionSelectorMatcher } from "../../selector/text/match-text-position"; +import { TextNodeChunker } from "../text-node-chunker"; +import { ITextPositionSelector } from "readium-desktop/common/readium/annotation/annotationModel.type"; + +/** + * Find the range of text corresponding to the given {@link + * TextPositionSelector}. + * + * The start and end positions are measured relative to the first text character + * in the given scope. + * + * The function is curried, taking first the selector and then the scope. + * + * Its end result is an (async) generator producing a single {@link https://developer.mozilla.org/en-US/docs/Web/API/Range + * | Range} to represent the match (unlike e.g. a {@link TextQuoteSelector}, a + * TextPositionSelector cannot have multiple matches). + * + * @example + * ``` + * const selector = { type: 'TextPositionSelector', start: 702, end: 736 }; + * const scope = document.body; + * const matches = textQuoteSelectorMatcher(selector)(scope); + * const match = (await matches.next()).value; + * // ⇒ Range { startContainer: #text, startOffset: 64, endContainer: #text, + * // endOffset: 98, … } + * ``` + * + * @param selector - The {@link TextPositionSelector} to be anchored. + * @returns A {@link Matcher} function that applies `selector` within a given + * `scope`. + * + * @public + */ +export function createTextPositionSelectorMatcher( + selector: ITextPositionSelector, +): Matcher { + const abstractMatcher = abstractTextPositionSelectorMatcher(selector); + + return async function* matchAll(scope) { + const textChunks = new TextNodeChunker(scope); + + const matches = abstractMatcher(textChunks); + + for await (const abstractMatch of matches) { + yield textChunks.chunkRangeToRange(abstractMatch); + } + }; +} diff --git a/src/third_party/apache-annotator/dom/text-quote/describe.ts b/src/third_party/apache-annotator/dom/text-quote/describe.ts new file mode 100644 index 000000000..2c08eee92 --- /dev/null +++ b/src/third_party/apache-annotator/dom/text-quote/describe.ts @@ -0,0 +1,78 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describeTextQuote as abstractDescribeTextQuote, type DescribeTextQuoteOptions } from "../../selector/text/describe-text-quote"; +import { ownerDocument } from "../owner-document"; +import { TextNodeChunker } from "../text-node-chunker"; +import { toRange } from "../to-range"; +import { ITextQuoteSelector } from "readium-desktop/common/readium/annotation/annotationModel.type"; + +/** + * Returns a {@link TextQuoteSelector} that unambiguously describes the given + * range of text, within the given scope. + * + * The selector will contain the *exact* target quote, and in case this quote + * appears multiple times in the text, sufficient context around the quote will + * be included in the selector’s *prefix* and *suffix* attributes to + * disambiguate. By default, more prefix and suffix are included than strictly + * required; both in order to be robust against slight modifications, and in an + * attempt to not end halfway a word (mainly for the sake of human readability). + * + * @example + * ``` + * const target = window.getSelection().getRangeAt(0); + * const selector = await describeTextQuote(target); + * console.log(selector); + * // { + * // type: 'TextQuoteSelector', + * // exact: 'ipsum', + * // prefix: 'Lorem ', + * // suffix: ' dolor' + * // } + * ``` + * + * @param range - The {@link https://developer.mozilla.org/en-US/docs/Web/API/Range + * | Range} whose text content will be described + * @param scope - A Node or Range that serves as the ‘document’ for purposes of + * finding occurrences and determining prefix and suffix. Defaults to the full + * Document that contains `range`. + * @param options - Options to fine-tune the function’s behaviour. + * @returns The selector unambiguously describing `range` within `scope`. + * + * @public + */ +export async function describeTextQuote( + range: Range, + scope?: Node | Range, + options: DescribeTextQuoteOptions = {}, +): Promise { + const scopeAsRange = toRange(scope ?? ownerDocument(range)); + + const chunker = new TextNodeChunker(scopeAsRange); + + return await abstractDescribeTextQuote( + chunker.rangeToChunkRange(range), + () => new TextNodeChunker(scopeAsRange), + options, + ); +} diff --git a/src/third_party/apache-annotator/dom/text-quote/index.ts b/src/third_party/apache-annotator/dom/text-quote/index.ts new file mode 100644 index 000000000..574545b26 --- /dev/null +++ b/src/third_party/apache-annotator/dom/text-quote/index.ts @@ -0,0 +1,25 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from "./describe"; +export * from "./match"; diff --git a/src/third_party/apache-annotator/dom/text-quote/match.ts b/src/third_party/apache-annotator/dom/text-quote/match.ts new file mode 100644 index 000000000..7e575e4b3 --- /dev/null +++ b/src/third_party/apache-annotator/dom/text-quote/match.ts @@ -0,0 +1,91 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Matcher } from "../../selector/types"; +import { textQuoteSelectorMatcher as abstractTextQuoteSelectorMatcher } from "../../selector/text/match-text-quote"; +import { TextNodeChunker, EmptyScopeError } from "../text-node-chunker"; +import { ITextQuoteSelector } from "readium-desktop/common/readium/annotation/annotationModel.type"; + +/** + * Find occurrences in a text matching the given {@link + * TextQuoteSelector}. + * + * This performs an exact search for the selector’s quote (including prefix and + * suffix) within the text contained in the given scope (a {@link + * https://developer.mozilla.org/en-US/docs/Web/API/Range | Range}). + * + * Note the match is based on strict character-by-character equivalence, i.e. + * it is sensitive to whitespace, capitalisation, etc. + * + * The function is curried, taking first the selector and then the scope. + * + * As there may be multiple matches for a given selector (when its prefix and + * suffix attributes are not sufficient to disambiguate it), the matcher will + * return an (async) generator that produces each match in the order they are + * found in the text. + * + * *XXX Modifying the DOM (e.g. to highlight the text) while the search is still + * running can mess up and result in an error or an infinite loop. See [issue + * #112](https://github.com/apache/incubator-annotator/issues/112).* + * + * @example + * ``` + * // Find the word ‘banana’. + * const selector = { type: 'TextQuoteSelector', exact: 'banana' }; + * const scope = document.body; + * + * // Read all matches. + * const matches = textQuoteSelectorMatcher(selector)(scope); + * for await (match of matches) console.log(match); + * // ⇒ Range { startContainer: #text, startOffset: 187, endContainer: #text, + * // endOffset: 193, … } + * // ⇒ Range { startContainer: #text, startOffset: 631, endContainer: #text, + * // endOffset: 637, … } + * ``` + * + * @param selector - The {@link TextQuoteSelector} to be anchored. + * @returns A {@link Matcher} function that applies `selector` within a given + * `scope`. + * + * @public + */ +export function createTextQuoteSelectorMatcher( + selector: ITextQuoteSelector, +): Matcher { + const abstractMatcher = abstractTextQuoteSelectorMatcher(selector); + + return async function* matchAll(scope) { + let textChunks; + try { + textChunks = new TextNodeChunker(scope); + } catch (err) { + // An empty range contains no matches. + if (err instanceof EmptyScopeError) return; + else throw err; + } + + for await (const abstractMatch of abstractMatcher(textChunks)) { + yield textChunks.chunkRangeToRange(abstractMatch); + } + }; +} diff --git a/src/third_party/apache-annotator/dom/to-range.ts b/src/third_party/apache-annotator/dom/to-range.ts new file mode 100644 index 000000000..1bf10209d --- /dev/null +++ b/src/third_party/apache-annotator/dom/to-range.ts @@ -0,0 +1,48 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ownerDocument } from "./owner-document"; + +/** + * Returns a range that exactly selects the contents of the given node. + * + * This function is idempotent: If the given argument is already a range, it + * simply returns that range. + * + * @param nodeOrRange The node/range to convert to a range if it is not already + * a range. + */ +export function toRange(nodeOrRange: Node | Range): Range { + if (isRange(nodeOrRange)) { + return nodeOrRange; + } else { + const node = nodeOrRange; + const range = ownerDocument(node).createRange(); + range.selectNodeContents(node); + return range; + } +} + +function isRange(nodeOrRange: Node | Range): nodeOrRange is Range { + return "startContainer" in nodeOrRange; +} diff --git a/src/third_party/apache-annotator/selector/index.ts b/src/third_party/apache-annotator/selector/index.ts new file mode 100644 index 000000000..4b2346c7b --- /dev/null +++ b/src/third_party/apache-annotator/selector/index.ts @@ -0,0 +1,33 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +export type { + Matcher, + Selector, + CssSelector, + RangeSelector, + TextPositionSelector, + TextQuoteSelector, +} from "./types"; +export * from "./text/index"; +export * from "./refinable"; diff --git a/src/third_party/apache-annotator/selector/refinable.ts b/src/third_party/apache-annotator/selector/refinable.ts new file mode 100644 index 000000000..3d5f8b367 --- /dev/null +++ b/src/third_party/apache-annotator/selector/refinable.ts @@ -0,0 +1,89 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ISelector } from "readium-desktop/common/readium/annotation/annotationModel.type"; +import type { Matcher, Selector } from "./types"; + +/** + * A Refinable selector can have the `refinedBy` attribute, whose value must be + * of the same type (possibly again refined, recursively). + * + * See {@link https://www.w3.org/TR/2017/REC-annotation-model-20170223/#refinement-of-selection + * | §4.2.9 Refinement of Selection} in the Web Annotation Data Model. + * + * @example + * Example value of type `Refinable`: + * + * { + * type: "CssSelector", + * …, + * refinedBy: { + * type: "TextQuoteSelector", + * …, + * refinedBy: { … }, // again either a CssSelector or TextQuoteSelector + * } + * } + */ +export type Refinable = T & { refinedBy?: Refinable }; + +/** + * Wrap a matcher creation function so that it supports refinement of selection. + * + * See {@link https://www.w3.org/TR/2017/REC-annotation-model-20170223/#refinement-of-selection + * | §4.2.9 Refinement of Selection} in the Web Annotation Data Model. + * + * @param matcherCreator - The function to wrap; it will be executed both for + * {@link Selector}s passed to the returned wrapper function, and for any + * refining Selector those might contain (and any refinement of that, etc.). + * + * @public + */ +export function makeRefinable< + TSelector extends ISelector, + TScope, + // To enable refinement, the implementation’s Match object must be usable as a + // Scope object itself. + TMatch extends TScope +>( + matcherCreator: (selector: TSelector) => Matcher, +): (selector: TSelector) => Matcher { + return function createMatcherWithRefinement( + sourceSelector: TSelector, + ): Matcher { + const matcher = matcherCreator(sourceSelector); + + if (sourceSelector.refinedBy) { + const refiningSelector = createMatcherWithRefinement( + sourceSelector.refinedBy, + ); + + return async function* matchAll(scope) { + for await (const match of matcher(scope)) { + yield* refiningSelector(match); + } + }; + } + + return matcher; + }; +} diff --git a/src/third_party/apache-annotator/selector/text/chunker.ts b/src/third_party/apache-annotator/selector/text/chunker.ts new file mode 100644 index 000000000..cb1147d15 --- /dev/null +++ b/src/third_party/apache-annotator/selector/text/chunker.ts @@ -0,0 +1,160 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Represents a piece of text in any kind of ‘file’. + * + * Its purpose is to enable generic algorithms to deal with text content of any + * type of ‘file’ that consists of many pieces of text (e.g. a DOM, PDF, …). + * Each Chunk represents one piece of text ({@link Chunk.data}). An object + * implementing this interface would typically have other attributes as well to + * map the chunk back to its position in the file (e.g. a Text node in the DOM). + * + * @typeParam TData - Piece of text, typically `string` + * + * @public + */ +export interface Chunk { + /** + * The piece of text this chunk represents. + */ + readonly data: TData; + equals?: (otherChunk: this) => boolean; +} + +/** + * Test two {@link Chunk}s for equality. + * + * Equality here means that both represent the same piece of text (i.e. at the + * same position) in the file. It compares using the custom {@link Chunk.equals} + * method if either chunk defines one, and falls back to checking the objects’ + * identity (i.e. `chunk1 === chunk2`). + * + * @public + */ +export function chunkEquals(chunk1: Chunk, chunk2: Chunk): boolean { + if (chunk1.equals) return chunk1.equals(chunk2); + if (chunk2.equals) return chunk2.equals(chunk1); + return chunk1 === chunk2; +} + +/** + * Points at a range of characters between two points inside {@link Chunk}s. + * + * Analogous to the DOM’s ({@link https://developer.mozilla.org/en-US/docs/Web/API/AbstractRange + * | Abstract}){@link https://developer.mozilla.org/en-US/docs/Web/API/Range | + * Range}. Each index expresses an offset inside the value of the corresponding + * {@link Chunk.data}, and can equal the length of that data in order to point + * to the position right after the chunk’s last character. + * + * @public + */ +export interface ChunkRange> { + startChunk: TChunk; + startIndex: number; + endChunk: TChunk; + endIndex: number; +} + +/** + * Test two {@link ChunkRange}s for equality. + * + * Equality here means equality of each of their four properties (i.e. + * {@link startChunk}, {@link startIndex}, + * {@link endChunk}, and {@link endIndex}). + * For the `startChunk`s and `endChunk`s, this function uses the custom + * {@link Chunk.equals} method if defined. + * + * Note that if the start/end of one range points at the end of a chunk, and the + * other to the start of a subsequent chunk, they are not considered equal, even + * though semantically they may be representing the same range of characters. To + * test for such semantic equivalence, ensure that both inputs are normalised: + * typically this means the range is shrunk to its narrowest equivalent, and (if + * it is empty) positioned at its first equivalent. + * + * @public + */ +export function chunkRangeEquals( + range1: ChunkRange, + range2: ChunkRange, +): boolean { + return ( + chunkEquals(range1.startChunk, range2.startChunk) && + chunkEquals(range1.endChunk, range2.endChunk) && + range1.startIndex === range2.startIndex && + range1.endIndex === range2.endIndex + ); +} + +/** + * Presents the pieces of text contained in some underlying ‘file’ as a sequence + * of {@link Chunk}s. + * + * Rather than presenting a list of all pieces, the `Chunker` provides methods + * to walk through the file piece by piece. This permits implementations to read + * and convert the file to `Chunk`s lazily. + * + * For those familiar with the DOM APIs, it is similar to a NodeIterator (but + * unlike NodeIterator, it has no concept of being ‘before’ or ‘after’ a chunk). + * + * @typeParam TChunk - (sub)type of `Chunk` being used. + * + * @public + */ +export interface Chunker> { + /** + * The chunk currently being pointed at. + * + * Initially, this should normally be the first chunk in the file. + */ + readonly currentChunk: TChunk; + + /** + * Point {@link currentChunk} at the chunk following it, and return that chunk. + * If there are no chunks following it, keep `currentChunk` unchanged and + * return null. + */ + nextChunk(): TChunk | null; + + /** + * Point {@link currentChunk} at the chunk preceding it, and return that chunk. + * If there are no chunks preceding it, keep `currentChunk` unchanged and + * return null. + */ + previousChunk(): TChunk | null; + + /** + * Test if a given `chunk` is before the {@link currentChunk|current + * chunk}. + * + * Returns true if `chunk` is before `this.currentChunk`, false otherwise + * (i.e. if `chunk` follows it or is the current chunk). + * + * The given `chunk` need not necessarily be obtained from the same `Chunker`, + * but the chunkers would need to represent the same file. Otherwise behaviour + * is unspecified (an implementation might throw or just return `false`). + * + * @param chunk - A chunk, typically obtained from the same `Chunker`. + */ + precedesCurrentChunk(chunk: TChunk): boolean; +} diff --git a/src/third_party/apache-annotator/selector/text/code-point-seeker.ts b/src/third_party/apache-annotator/selector/text/code-point-seeker.ts new file mode 100644 index 000000000..06314423d --- /dev/null +++ b/src/third_party/apache-annotator/selector/text/code-point-seeker.ts @@ -0,0 +1,200 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Chunk } from "./chunker"; +import type { Seeker } from "./seeker"; + +/** + * Seeks through text counting Unicode *code points* instead of *code units*. + * + * Javascript characters correspond to 16 bits *code units*, hence two such + * ‘characters’ might together constitute a single Unicode character (i.e. a + * *code point*). The {@link CodePointSeeker} allows to ignore this + * variable-length encoding, by counting code points instead. + * + * It is made to wrap a {@link Seeker} that counts code units (presumably a + * {@link TextSeeker}), which must be passed to its {@link constructor}. + * + * When reading from the `CodePointSeeker`, the returned values is not a string + * but an array of strings, each containing one code point (thus each having a + * `length` that is either 1 or 2). + * + * @public + */ +export class CodePointSeeker> + implements Seeker { + position = 0; + + /** + * + * @param raw The {@link Seeker} to wrap, which counts in code *units* (e.g. + * a {@link TextSeeker}). It should have {@link Seeker.position | position} + * `0` and its methods must no longer be used directly if the + * `CodePointSeeker`’s position is to remain correct. + */ + constructor(public readonly raw: Seeker) {} + + seekBy(length: number): void { + this.seekTo(this.position + length); + } + + seekTo(target: number): void { + this._readOrSeekTo(false, target); + } + + read(length: number, roundUp?: boolean): string[] { + return this.readTo(this.position + length, roundUp); + } + + readTo(target: number, roundUp?: boolean): string[] { + return this._readOrSeekTo(true, target, roundUp); + } + + get currentChunk(): TChunk { + return this.raw.currentChunk; + } + + get offsetInChunk(): number { + return this.raw.offsetInChunk; + } + + seekToChunk(target: TChunk, offset = 0): void { + this._readOrSeekToChunk(false, target, offset); + } + + readToChunk(target: TChunk, offset = 0): string[] { + return this._readOrSeekToChunk(true, target, offset); + } + + private _readOrSeekToChunk( + read: true, + target: TChunk, + offset?: number, + ): string[]; + private _readOrSeekToChunk( + read: false, + target: TChunk, + offset?: number, + ): void; + private _readOrSeekToChunk(read: boolean, target: TChunk, offset = 0) { + const oldRawPosition = this.raw.position; + + let s = this.raw.readToChunk(target, offset); + + const movedForward = this.raw.position >= oldRawPosition; + + if (movedForward && endsWithinCharacter(s)) { + this.raw.seekBy(-1); + s = s.slice(0, -1); + } else if (!movedForward && startsWithinCharacter(s)) { + this.raw.seekBy(1); + s = s.slice(1); + } + + const result = [...s]; + + this.position = movedForward + ? this.position + result.length + : this.position - result.length; + + if (read) return result; + return undefined; + } + + private _readOrSeekTo( + read: true, + target: number, + roundUp?: boolean, + ): string[]; + private _readOrSeekTo(read: false, target: number, roundUp?: boolean): void; + private _readOrSeekTo( + read: boolean, + target: number, + roundUp = false, + ): string[] | void { + let result: string[] = []; + + if (this.position < target) { + let unpairedSurrogate = ""; + let characters: string[] = []; + while (this.position < target) { + let s = unpairedSurrogate + this.raw.read(1, true); + if (endsWithinCharacter(s)) { + unpairedSurrogate = s.slice(-1); // consider this half-character part of the next string. + s = s.slice(0, -1); + } else { + unpairedSurrogate = ""; + } + characters = [...s]; + this.position += characters.length; + if (read) result = result.concat(characters); + } + if (unpairedSurrogate) this.raw.seekBy(-1); // align with the last complete character. + if (!roundUp && this.position > target) { + const overshootInCodePoints = this.position - target; + const overshootInCodeUnits = characters + .slice(-overshootInCodePoints) + .join("").length; + this.position -= overshootInCodePoints; + this.raw.seekBy(-overshootInCodeUnits); + } + } else { + // Nearly equal to the if-block, but moving backward in the text. + let unpairedSurrogate = ""; + let characters: string[] = []; + while (this.position > target) { + let s = this.raw.read(-1, true) + unpairedSurrogate; + if (startsWithinCharacter(s)) { + unpairedSurrogate = s[0]; + s = s.slice(1); + } else { + unpairedSurrogate = ""; + } + characters = [...s]; + this.position -= characters.length; + if (read) result = characters.concat(result); + } + if (unpairedSurrogate) this.raw.seekBy(1); + if (!roundUp && this.position < target) { + const overshootInCodePoints = target - this.position; + const overshootInCodeUnits = characters + .slice(0, overshootInCodePoints) + .join("").length; + this.position += overshootInCodePoints; + this.raw.seekBy(overshootInCodeUnits); + } + } + + if (read) return result; + } +} + +function endsWithinCharacter(s: string) { + const codeUnit = s.charCodeAt(s.length - 1); + return 0xd800 <= codeUnit && codeUnit <= 0xdbff; +} + +function startsWithinCharacter(s: string) { + const codeUnit = s.charCodeAt(0); + return 0xdc00 <= codeUnit && codeUnit <= 0xdfff; +} diff --git a/src/third_party/apache-annotator/selector/text/describe-text-position.ts b/src/third_party/apache-annotator/selector/text/describe-text-position.ts new file mode 100644 index 000000000..a8929de73 --- /dev/null +++ b/src/third_party/apache-annotator/selector/text/describe-text-position.ts @@ -0,0 +1,64 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ITextPositionSelector } from "readium-desktop/common/readium/annotation/annotationModel.type"; +import type { Chunk, Chunker, ChunkRange } from "./chunker"; +import { CodePointSeeker } from "./code-point-seeker"; +import { TextSeeker } from "./seeker"; + +/** + * Returns a {@link TextPositionSelector} that points at the target text within + * the given scope. + * + * This is an abstract implementation of the function’s logic, which expects a + * generic {@link Chunker} to represent the text, and a {@link ChunkRange} to + * represent the target. + * + * See {@link dom.describeTextPosition} for a wrapper around + * this implementation which applies it to the text of an HTML DOM. + * + * @param target - The range of characters that the selector should describe + * @param scope - The text, presented as a {@link Chunker}, which contains the + * target range, and relative to which its position will be measured + * @returns The {@link TextPositionSelector} that describes `target` relative + * to `scope` + * + * @public + */ +export async function describeTextPosition>( + target: ChunkRange, + scope: Chunker, +): Promise { + const codeUnitSeeker = new TextSeeker(scope); + const codePointSeeker = new CodePointSeeker(codeUnitSeeker); + + codePointSeeker.seekToChunk(target.startChunk, target.startIndex); + const start = codePointSeeker.position; + codePointSeeker.seekToChunk(target.endChunk, target.endIndex); + const end = codePointSeeker.position; + return { + type: "TextPositionSelector", + start, + end, + }; +} diff --git a/src/third_party/apache-annotator/selector/text/describe-text-quote.ts b/src/third_party/apache-annotator/selector/text/describe-text-quote.ts new file mode 100644 index 000000000..95aed9557 --- /dev/null +++ b/src/third_party/apache-annotator/selector/text/describe-text-quote.ts @@ -0,0 +1,305 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ITextQuoteSelector } from "readium-desktop/common/readium/annotation/annotationModel.type"; +import type { Chunk, Chunker, ChunkRange } from "./chunker"; +import { chunkRangeEquals } from "./chunker"; +import { textQuoteSelectorMatcher } from "./match-text-quote"; +import type { RelativeSeeker } from "./seeker"; +import { TextSeeker } from "./seeker"; + +/** + * @public + */ +export interface DescribeTextQuoteOptions { + /** + * Keep prefix and suffix to the minimum that is necessary to disambiguate + * the quote. Use only if robustness against text variations is not required. + */ + minimalContext?: boolean; + + /** + * Add prefix and suffix to quotes below this length, such that the total of + * `prefix + exact + suffix` is at least this length. + */ + minimumQuoteLength?: number; + + /** + * When attempting to find a whitespace to make the prefix/suffix start/end + * (resp.) at a word boundary, give up after this number of characters. + */ + maxWordLength?: number; +} + +/** + * Returns a {@link TextQuoteSelector} that points at the target quote in the + * given text. + * + * The selector will contain the exact target quote. In case this quote appears + * multiple times in the text, sufficient context around the quote will be + * included in the selector’s `prefix` and `suffix` attributes to disambiguate. + * By default, more prefix and suffix are included than strictly required; both + * in order to be robust against slight modifications, and in an attempt to not + * end halfway a word (mainly for human readability). + * + * This is an abstract implementation of the function’s logic, which expects a + * generic {@link Chunker} to represent the text, and a {@link ChunkRange} to + * represent the target. + * + * See {@link dom.describeTextQuote} for a wrapper around this + * implementation which applies it to the text of an HTML DOM. + * + * @param target - The range of characters that the selector should describe + * @param scope - The text containing the target range; or, more accurately, a + * function that produces {@link Chunker}s corresponding to this text. + * @param options - Options to fine-tune the function’s behaviour. + * @returns The {@link TextQuoteSelector} that describes `target`. + * + * @public + */ +export async function describeTextQuote>( + target: ChunkRange, + scope: () => Chunker, + options: DescribeTextQuoteOptions = {}, +): Promise { + const { + minimalContext = false, + minimumQuoteLength = 0, + maxWordLength = 50, + } = options; + + // Create a seeker to read the target quote and the context around it. + // TODO Possible optimisation: as it need not be an AbsoluteSeeker, a + // different implementation could provide direct ‘jump’ access in seekToChunk + // (the scope’s Chunker would of course also have to support this). + const seekerAtTarget = new TextSeeker(scope()); + + // Create a second seeker so that we will be able to simultaneously read + // characters near both the target and an unintended match, if we find any. + const seekerAtUnintendedMatch = new TextSeeker(scope()); + + // Read the target’s exact text. + seekerAtTarget.seekToChunk(target.startChunk, target.startIndex); + const exact = seekerAtTarget.readToChunk(target.endChunk, target.endIndex); + + // Start with an empty prefix and suffix. + let prefix = ""; + let suffix = ""; + + // If the quote is below the given minimum length, add some prefix & suffix. + const currentQuoteLength = () => prefix.length + exact.length + suffix.length; + if (currentQuoteLength() < minimumQuoteLength) { + // Expand the prefix, but only to reach halfway towards the desired length. + seekerAtTarget.seekToChunk( + target.startChunk, + target.startIndex - prefix.length, + ); + const length = Math.floor((minimumQuoteLength - currentQuoteLength()) / 2); + prefix = seekerAtTarget.read(-length, false, true) + prefix; + + // If needed, expand the suffix to achieve the minimum length. + if (currentQuoteLength() < minimumQuoteLength) { + seekerAtTarget.seekToChunk( + target.endChunk, + target.endIndex + suffix.length, + ); + const length = minimumQuoteLength - currentQuoteLength(); + suffix = suffix + seekerAtTarget.read(length, false, true); + + // We might have to expand the prefix again (if at the end of the scope). + if (currentQuoteLength() < minimumQuoteLength) { + seekerAtTarget.seekToChunk( + target.startChunk, + target.startIndex - prefix.length, + ); + const length = minimumQuoteLength - currentQuoteLength(); + prefix = seekerAtTarget.read(-length, false, true) + prefix; + } + } + } + + // Expand prefix & suffix to avoid them ending somewhere halfway in a word. + if (!minimalContext) { + seekerAtTarget.seekToChunk( + target.startChunk, + target.startIndex - prefix.length, + ); + prefix = readUntilWhitespace(seekerAtTarget, maxWordLength, true) + prefix; + seekerAtTarget.seekToChunk( + target.endChunk, + target.endIndex + suffix.length, + ); + suffix = suffix + readUntilWhitespace(seekerAtTarget, maxWordLength, false); + } + + // Search for matches of the quote using the current prefix and suffix. At + // each unintended match we encounter, we extend the prefix or suffix to + // ensure it will no longer match. + while (true) { + const tentativeSelector: ITextQuoteSelector = { + type: "TextQuoteSelector", + exact, + prefix, + suffix, + }; + + const matches = textQuoteSelectorMatcher(tentativeSelector)(scope()); + let nextMatch = await matches.next(); + const nextMatchValue = nextMatch.value; + + // If this match is the intended one, no need to act. + // XXX This test is fragile: nextMatch and target are assumed to be normalised. + if (!nextMatch.done && nextMatchValue && chunkRangeEquals(nextMatchValue, target)) { + nextMatch = await matches.next(); + } + + // If there are no more unintended matches, our selector is unambiguous! + if (nextMatch.done) return tentativeSelector; + + // Possible optimisation: A subsequent search could safely skip the part we + // already processed, instead of starting from the beginning again. But we’d + // need the matcher to start at the seeker’s position, instead of searching + // in the whole current chunk. Then we could just seek back to just after + // the start of the prefix: seeker.seekBy(-prefix.length + 1); (don’t forget + // to also correct for any changes in the prefix we will make below) + + // We’ll have to add more prefix/suffix to disqualify this unintended match. + const unintendedMatch = nextMatch.value; + if (!unintendedMatch) { + throw new Error("Unreacheable unintendedMatch equal `void` type"); + } + + // Count how many characters we’d need as a prefix to disqualify this match. + seekerAtTarget.seekToChunk( + target.startChunk, + target.startIndex - prefix.length, + ); + seekerAtUnintendedMatch.seekToChunk( + unintendedMatch.startChunk, + unintendedMatch.startIndex - prefix.length, + ); + let extraPrefix = readUntilDifferent( + seekerAtTarget, + seekerAtUnintendedMatch, + true, + ); + if (extraPrefix !== undefined && !minimalContext) + extraPrefix = + readUntilWhitespace(seekerAtTarget, maxWordLength, true) + extraPrefix; + + // Count how many characters we’d need as a suffix to disqualify this match. + seekerAtTarget.seekToChunk( + target.endChunk, + target.endIndex + suffix.length, + ); + seekerAtUnintendedMatch.seekToChunk( + unintendedMatch.endChunk, + unintendedMatch.endIndex + suffix.length, + ); + let extraSuffix = readUntilDifferent( + seekerAtTarget, + seekerAtUnintendedMatch, + false, + ); + if (extraSuffix !== undefined && !minimalContext) + extraSuffix = + extraSuffix + readUntilWhitespace(seekerAtTarget, maxWordLength, false); + + if (minimalContext) { + // Use either the prefix or suffix, whichever is shortest. + if ( + extraPrefix !== undefined && + (extraSuffix === undefined || extraPrefix.length <= extraSuffix.length) + ) { + prefix = extraPrefix + prefix; + } else if (extraSuffix !== undefined) { + suffix = suffix + extraSuffix; + } else { + throw new Error( + "Target cannot be disambiguated; how could that have happened‽", + ); + } + } else { + // For redundancy, expand both prefix and suffix. + if (extraPrefix !== undefined) prefix = extraPrefix + prefix; + if (extraSuffix !== undefined) suffix = suffix + extraSuffix; + } + } +} + +function readUntilDifferent( + seeker1: RelativeSeeker, + seeker2: RelativeSeeker, + reverse: boolean, +): string | undefined { + let result = ""; + while (true) { + let nextCharacter: string; + try { + nextCharacter = seeker1.read(reverse ? -1 : 1); + } catch { + return undefined; // Start/end of text reached: cannot expand result. + } + result = reverse ? nextCharacter + result : result + nextCharacter; + + // Check if the newly added character makes the result differ from the second seeker. + let comparisonCharacter: string | undefined; + try { + comparisonCharacter = seeker2.read(reverse ? -1 : 1); + } catch (err) { + // A RangeError would merely mean seeker2 is exhausted. + if (!(err instanceof RangeError)) throw err; + } + if (nextCharacter !== comparisonCharacter) return result; + } +} + +function readUntilWhitespace( + seeker: RelativeSeeker, + limit = Infinity, + reverse = false, +): string { + let result = ""; + while (result.length < limit) { + let nextCharacter: string; + try { + nextCharacter = seeker.read(reverse ? -1 : 1); + } catch (err) { + if (!(err instanceof RangeError)) throw err; + break; // End/start of text reached. + } + + // Stop if we reached whitespace. + if (isWhitespace(nextCharacter)) { + seeker.seekBy(reverse ? 1 : -1); // ‘undo’ the last read. + break; + } + + result = reverse ? nextCharacter + result : result + nextCharacter; + } + return result; +} + +function isWhitespace(s: string): boolean { + return /^\s+$/.test(s); +} diff --git a/src/third_party/apache-annotator/selector/text/index.ts b/src/third_party/apache-annotator/selector/text/index.ts new file mode 100644 index 000000000..5a64517c9 --- /dev/null +++ b/src/third_party/apache-annotator/selector/text/index.ts @@ -0,0 +1,28 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from "./describe-text-quote"; +export * from "./match-text-quote"; +export * from "./describe-text-position"; +export * from "./match-text-position"; +export * from "./chunker"; diff --git a/src/third_party/apache-annotator/selector/text/match-text-position.ts b/src/third_party/apache-annotator/selector/text/match-text-position.ts new file mode 100644 index 000000000..edad547ba --- /dev/null +++ b/src/third_party/apache-annotator/selector/text/match-text-position.ts @@ -0,0 +1,79 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { TextPositionSelector } from "../types"; +import type { Chunk, ChunkRange, Chunker } from "./chunker"; +import { CodePointSeeker } from "./code-point-seeker"; +import { TextSeeker } from "./seeker"; + +/** + * Find the range of text corresponding to the given {@link TextPositionSelector}. + * + * This is an abstract implementation of the function’s logic, which expects a + * generic {@link Chunker} to represent the text, and returns an (async) + * generator producing a single {@link ChunkRange} to represent the match. + * (unlike e.g. TextQuoteSelector, it cannot result in multiple matches). + * + * See {@link dom.createTextPositionSelectorMatcher} for a + * wrapper around this implementation which applies it to the text of an HTML + * DOM. + * + * The function is curried, taking first the selector and then the text. + * + * @example + * ``` + * const selector = { type: 'TextPositionSelector', start: 702, end: 736 }; + * const matches = textPositionSelectorMatcher(selector)(textChunks); + * const match = (await matches.next()).value; + * console.log(match); + * // ⇒ { startChunk: { … }, startIndex: 64, endChunk: { … }, endIndex: 98 } + * ``` + * + * @param selector - the {@link TextPositionSelector} to be anchored + * @returns a {@link Matcher} function that applies `selector` to a given text + * + * @public + */ +export function textPositionSelectorMatcher( + selector: TextPositionSelector, +): >( + scope: Chunker, +) => AsyncGenerator, void, void> { + const { start, end } = selector; + + return async function* matchAll>( + textChunks: Chunker, + ) { + const codeUnitSeeker = new TextSeeker(textChunks); + const codePointSeeker = new CodePointSeeker(codeUnitSeeker); + + codePointSeeker.seekTo(start); + const startChunk = codeUnitSeeker.currentChunk; + const startIndex = codeUnitSeeker.offsetInChunk; + codePointSeeker.seekTo(end); + const endChunk = codeUnitSeeker.currentChunk; + const endIndex = codeUnitSeeker.offsetInChunk; + + yield { startChunk, startIndex, endChunk, endIndex }; + }; +} diff --git a/src/third_party/apache-annotator/selector/text/match-text-quote.ts b/src/third_party/apache-annotator/selector/text/match-text-quote.ts new file mode 100644 index 000000000..49a1158b3 --- /dev/null +++ b/src/third_party/apache-annotator/selector/text/match-text-quote.ts @@ -0,0 +1,214 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ITextQuoteSelector } from "readium-desktop/common/readium/annotation/annotationModel.type"; +import type { Chunk, Chunker, ChunkRange } from "./chunker"; + +/** + * Find occurrences in a text matching the given {@link TextQuoteSelector}. + * + * This performs an exact search the selector’s quote (including prefix and + * suffix) within the given text. + * + * Note the match is based on strict character-by-character equivalence, i.e. + * it is sensitive to whitespace, capitalisation, etc. + * + * This is an abstract implementation of the function’s logic, which expects a + * generic {@link Chunker} to represent the text, and returns an (async) + * generator of {@link ChunkRange}s to represent the matches. + * + * See {@link dom.createTextQuoteSelectorMatcher} for a + * wrapper around this implementation which applies it to the text of an HTML + * DOM. + * + * The function is curried, taking first the selector and then the text. + * + * As there may be multiple matches for a given selector (when its prefix and + * suffix attributes are not sufficient to disambiguate it), the matcher will + * return an (async) generator that produces each match in the order they are + * found in the text. + * + * *XXX Modifying the Chunks while the search is still running can mess up and + * result in an error or an infinite loop. See [issue #112](https://github.com/apache/incubator-annotator/issues/112).* + * + * @example + * ``` + * const selector = { type: 'TextQuoteSelector', exact: 'banana' }; + * const matches = textQuoteSelectorMatcher(selector)(textChunks); + * for await (match of matches) console.log(match); + * // ⇒ { startChunk: { … }, startIndex: 187, endChunk: { … }, endIndex: 193 } + * // ⇒ { startChunk: { … }, startIndex: 631, endChunk: { … }, endIndex: 637 } + * ``` + * + * @param selector - The {@link TextQuoteSelector} to be anchored + * @returns a {@link Matcher} function that applies `selector` to a given text + * + * @public + */ +export function textQuoteSelectorMatcher( + selector: ITextQuoteSelector, +): >( + scope: Chunker, +) => AsyncGenerator, void, void> { + return async function* matchAll>( + textChunks: Chunker, + ) { + const exact = selector.exact; + const prefix = selector.prefix || ""; + const suffix = selector.suffix || ""; + const searchPattern = prefix + exact + suffix; + + // The code below essentially just performs string.indexOf(searchPattern), + // but on a string that is chopped up in multiple chunks. It runs a loop + // containing three steps: + // 1. Continue checking any partial matches from the previous chunk(s). + // 2. Try find the whole pattern in the chunk (possibly multiple times). + // 3. Check if this chunk ends with a partial match (or even multiple partial matches). + + interface PartialMatch { + startChunk?: TChunk; + startIndex?: number; + endChunk?: TChunk; + endIndex?: number; + charactersMatched: number; + } + let partialMatches: PartialMatch[] = []; + + let isFirstChunk = true; + do { + const chunk = textChunks.currentChunk; + const chunkValue = chunk.data; + + // 1. Continue checking any partial matches from the previous chunk(s). + const remainingPartialMatches: typeof partialMatches = []; + for (const partialMatch of partialMatches) { + const charactersMatched = partialMatch.charactersMatched; + + // If the current chunk contains the start and/or end of the match, record these. + if (partialMatch.endChunk === undefined) { + const charactersUntilMatchEnd = + prefix.length + exact.length - charactersMatched; + if (charactersUntilMatchEnd <= chunkValue.length) { + partialMatch.endChunk = chunk; + partialMatch.endIndex = charactersUntilMatchEnd; + } + } + if (partialMatch.startChunk === undefined) { + const charactersUntilMatchStart = prefix.length - charactersMatched; + if ( + charactersUntilMatchStart < chunkValue.length || + partialMatch.endChunk !== undefined // handles an edge case: an empty quote at the end of a chunk. + ) { + partialMatch.startChunk = chunk; + partialMatch.startIndex = charactersUntilMatchStart; + } + } + + const charactersUntilSuffixEnd = + searchPattern.length - charactersMatched; + if (charactersUntilSuffixEnd <= chunkValue.length) { + if ( + chunkValue.startsWith(searchPattern.substring(charactersMatched)) + ) { + yield partialMatch as ChunkRange; // all fields are certainly defined now. + } + } else if ( + chunkValue === + searchPattern.substring( + charactersMatched, + charactersMatched + chunkValue.length, + ) + ) { + // The chunk is too short to complete the match; comparison has to be completed in subsequent chunks. + partialMatch.charactersMatched += chunkValue.length; + remainingPartialMatches.push(partialMatch); + } + } + partialMatches = remainingPartialMatches; + + // 2. Try find the whole pattern in the chunk (possibly multiple times). + if (searchPattern.length <= chunkValue.length) { + let fromIndex = 0; + while (fromIndex <= chunkValue.length) { + const patternStartIndex = chunkValue.indexOf( + searchPattern, + fromIndex, + ); + if (patternStartIndex === -1) break; + fromIndex = patternStartIndex + 1; + + // Handle edge case: an empty searchPattern would already have been yielded at the end of the last chunk. + if ( + patternStartIndex === 0 && + searchPattern.length === 0 && + !isFirstChunk + ) + continue; + + yield { + startChunk: chunk, + startIndex: patternStartIndex + prefix.length, + endChunk: chunk, + endIndex: patternStartIndex + prefix.length + exact.length, + }; + } + } + + // 3. Check if this chunk ends with a partial match (or even multiple partial matches). + let newPartialMatches: number[] = []; + const searchStartPoint = Math.max( + chunkValue.length - searchPattern.length + 1, + 0, + ); + for (let i = searchStartPoint; i < chunkValue.length; i++) { + const character = chunkValue[i]; + newPartialMatches = newPartialMatches.filter( + (partialMatchStartIndex) => + character === searchPattern[i - partialMatchStartIndex], + ); + if (character === searchPattern[0]) newPartialMatches.push(i); + } + for (const partialMatchStartIndex of newPartialMatches) { + const charactersMatched = chunkValue.length - partialMatchStartIndex; + const partialMatch: PartialMatch = { + charactersMatched, + }; + if (charactersMatched >= prefix.length + exact.length) { + partialMatch.endChunk = chunk; + partialMatch.endIndex = + partialMatchStartIndex + prefix.length + exact.length; + } + if ( + charactersMatched > prefix.length || + partialMatch.endChunk !== undefined // handles an edge case: an empty quote at the end of a chunk. + ) { + partialMatch.startChunk = chunk; + partialMatch.startIndex = partialMatchStartIndex + prefix.length; + } + partialMatches.push(partialMatch); + } + + isFirstChunk = false; + } while (textChunks.nextChunk() !== null); + }; +} diff --git a/src/third_party/apache-annotator/selector/text/seeker.ts b/src/third_party/apache-annotator/selector/text/seeker.ts new file mode 100644 index 000000000..11f24a64a --- /dev/null +++ b/src/third_party/apache-annotator/selector/text/seeker.ts @@ -0,0 +1,418 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Chunk, Chunker } from "./chunker"; +import { chunkEquals } from "./chunker"; + +const E_END = "Iterator exhausted before seek ended."; + +/** + * Abstraction to seek (jump) or read to a position inside a ‘file’ consisting of a + * sequence of data chunks. + * + * This interface is a combination of three interfaces in one: for seeking to a + * relative position, an absolute position, or a specific chunk. These three are + * defined separately for clarity and flexibility, but normally used together. + * + * A Seeker internally maintains a pointer to the chunk it is currently ‘in’ and + * the offset position within that chunk. + * + * @typeParam TChunk - Type of chunks the file consists of. + * @typeParam TData - Type of data this seeker’s read methods will return (not + * necessarily the same as the `TData` parameter of {@link Chunk}, see e.g. + * {@link CodePointSeeker}) + * + * @public + */ +export interface Seeker< + TChunk extends Chunk, + TData extends Iterable = string +> + extends RelativeSeeker, + AbsoluteSeeker, + ChunkSeeker {} + +/** + * Seeks/reads by a given number of characters. + * + * @public + */ +export interface RelativeSeeker = string> { + /** + * Move forward or backward by a number of characters. + * + * @param length - The number of characters to pass. A negative number moves + * backwards in the file. + * @throws RangeError if there are not enough characters in the file. The + * pointer is left at the end/start of the file. + */ + seekBy(length: number): void; + + /** + * Read forward or backward by a number of characters. + * + * Equal to {@link seekBy}, but returning the characters passed. + * + * @param length - The number of characters to read. A negative number moves + * backwards in the file. + * @param roundUp - If true, then, after reading the given number of + * characters, read further until the end (or start) of the current chunk. + * @param lessIsFine - If true, and there are not enough characters in the + * file, return the result so far instead of throwing an error. + * @returns The characters passed (in their normal order, even when moving + * backwards) + * @throws RangeError if there are not enough characters in the file (unless + * `lessIsFine` is true). The pointer is left at the end/start of the file. + */ + read(length?: number, roundUp?: boolean, lessIsFine?: boolean): TData; +} + +/** + * Seek/read to absolute positions in the file. + * + * @public + */ +export interface AbsoluteSeeker = string> { + /** + * The current position in the file in terms of character count: i.e. the + * number of characters before the place currently being pointed at. + */ + readonly position: number; + + /** + * Move to the given position in the file. + * + * @param target - The position to end up at. + * @throws RangeError if the given position is beyond the end/start of the + * file. The pointer is left at the end/start of the file. + */ + seekTo(target: number): void; + + /** + * Read forward or backward from the current to the given position in the + * file, returning the characters that have been passed. + * + * Equal to {@link seekTo}, but returning the characters passed. + * + * @param target - The position to end up at. + * @param roundUp - If true, then, after reading to the target position, read + * further until the end (or start) of the current chunk. + * @returns The characters passed (in their normal order, even when moving + * backwards) + * @throws RangeError if the given position is beyond the end/start of the + * file. The pointer is left at the end/start of the file. + */ + readTo(target: number, roundUp?: boolean): TData; +} + +/** + * Seek/read to (and within) specfic chunks the file consists of; and access the + * chunk and offset in that chunk corresponding to the current position. + * + * Note that all offset numbers in this interface are representing units of the + * {@link Chunk.data | data type of `TChunk`}; which might differ from that of + * `TData`. + * + * @public + */ +export interface ChunkSeeker< + TChunk extends Chunk, + TData extends Iterable = string +> { + /** + * The chunk containing the current position. + * + * When the position falls at the edge between two chunks, `currentChunk` is + * always the later one (thus {@link offsetInChunk} would be zero). Note that + * an empty chunk (for which position zero is at both its edges) can + * hence never be the current chunk unless it is the last chunk in the file. + */ + readonly currentChunk: TChunk; + + /** + * The offset inside `currentChunk` corresponding to the current position. + * Can be between zero and the length of the chunk (inclusive; but it could + * equal the length of the chunk only if currentChunk is the last chunk). + */ + readonly offsetInChunk: number; + + /** + * Move to the start of a given chunk, or to an offset relative to that. + * + * @param chunk - The chunk of the file to move to. + * @param offset - The offset to move to, relative to the start of `chunk`. + * Defaults to zero. + * @throws RangeError if the given chunk is not found in the file. + */ + seekToChunk(chunk: TChunk, offset?: number): void; + + /** + * Read to the start of a given chunk, or to an offset relative to that. + * + * Equal to {@link seekToChunk}, but returning the characters passed. + * + * @param chunk - The chunk of the file to move to. + * @param offset - The offset to move to, relative to the start of `chunk`. + * Defaults to zero. + * @returns The characters passed (in their normal order, even when moving + * backwards) + * @throws RangeError if the given chunk is not found in the file. + */ + readToChunk(chunk: TChunk, offset?: number): TData; +} + +/** + * A TextSeeker is constructed around a {@link Chunker}, to let it be treated as + * a continuous sequence of characters. + * + * Seeking to a given numeric position will cause a `TextSeeker` to pull chunks + * from the underlying `Chunker`, counting their lengths until the requested + * position is reached. `Chunks` are not stored but simply read again when + * seeking backwards. + * + * The `Chunker` is presumed to read an unchanging file. If a chunk’s length + * would change while seeking, a TextSeeker’s absolute positioning would be + * incorrect. + * + * See {@link CodePointSeeker} for a {@link Seeker} that counts Unicode *code + * points* instead of Javascript’s ‘normal’ characters. + * + * @public + */ +export class TextSeeker> + implements Seeker { + // The chunk containing our current text position. + get currentChunk(): TChunk { + return this.chunker.currentChunk; + } + + // The index of the first character of the current chunk inside the text. + private currentChunkPosition = 0; + + // The position inside the chunk where the last seek ended up. + offsetInChunk = 0; + + // The current text position (measured in code units) + get position(): number { + return this.currentChunkPosition + this.offsetInChunk; + } + + constructor(protected chunker: Chunker) { + // Walk to the start of the first non-empty chunk inside the scope. + this.seekTo(0); + } + + read(length: number, roundUp = false, lessIsFine = false): string { + return this._readOrSeekTo( + true, + this.position + length, + roundUp, + lessIsFine, + ); + } + + readTo(target: number, roundUp = false): string { + return this._readOrSeekTo(true, target, roundUp); + } + + seekBy(length: number): void { + this.seekTo(this.position + length); + } + + seekTo(target: number): void { + this._readOrSeekTo(false, target); + } + + seekToChunk(target: TChunk, offset = 0): void { + this._readOrSeekToChunk(false, target, offset); + } + + readToChunk(target: TChunk, offset = 0): string { + return this._readOrSeekToChunk(true, target, offset); + } + + private _readOrSeekToChunk( + read: true, + target: TChunk, + offset?: number, + ): string; + private _readOrSeekToChunk( + read: false, + target: TChunk, + offset?: number, + ): void; + private _readOrSeekToChunk( + read: boolean, + target: TChunk, + offset = 0, + ): string | void { + const oldPosition = this.position; + let result = ""; + + // Walk to the requested chunk. + if (!this.chunker.precedesCurrentChunk(target)) { + // Search forwards. + while (!chunkEquals(this.currentChunk, target)) { + const [data, nextChunk] = this._readToNextChunk(); + if (read) result += data; + if (nextChunk === null) throw new RangeError(E_END); + } + } else { + // Search backwards. + while (!chunkEquals(this.currentChunk, target)) { + const [data, previousChunk] = this._readToPreviousChunk(); + if (read) result = data + result; + if (previousChunk === null) throw new RangeError(E_END); + } + } + + // Now we know where the chunk is, walk to the requested offset. + // Note we might have started inside the chunk, and the offset could even + // point at a position before or after the chunk. + const targetPosition = this.currentChunkPosition + offset; + if (!read) { + this.seekTo(targetPosition); + } else { + if (targetPosition >= this.position) { + // Read further until the target. + result += this.readTo(targetPosition); + } else if (targetPosition >= oldPosition) { + // We passed by our target position: step back. + this.seekTo(targetPosition); + result = result.slice(0, targetPosition - oldPosition); + } else { + // The target precedes our starting position: read backwards from there. + this.seekTo(oldPosition); + result = this.readTo(targetPosition); + } + return result; + } + } + + private _readOrSeekTo( + read: true, + target: number, + roundUp?: boolean, + lessIsFine?: boolean, + ): string; + private _readOrSeekTo( + read: false, + target: number, + roundUp?: boolean, + lessIsFine?: boolean, + ): void; + private _readOrSeekTo( + read: boolean, + target: number, + roundUp = false, + lessIsFine = false, + ): string | void { + let result = ""; + + if (this.position <= target) { + while (true) { + const endOfChunk = + this.currentChunkPosition + this.currentChunk.data.length; + if (endOfChunk <= target) { + // The target is beyond the current chunk. + // (we use ≤ not <: if the target is *at* the end of the chunk, possibly + // because the current chunk is empty, we prefer to take the next chunk) + + const [data, nextChunk] = this._readToNextChunk(); + if (read) result += data; + if (nextChunk === null) { + if (this.position === target || lessIsFine) break; + else throw new RangeError(E_END); + } + } else { + // The target is within the current chunk. + const newOffset = roundUp + ? this.currentChunk.data.length + : target - this.currentChunkPosition; + if (read) + result += this.currentChunk.data.substring( + this.offsetInChunk, + newOffset, + ); + this.offsetInChunk = newOffset; + + // If we finish end at the end of the chunk, seek to the start of the next non-empty node. + // (TODO decide: should we keep this guarantee of not finishing at the end of a chunk?) + if (roundUp) this.seekBy(0); + + break; + } + } + } else { + // Similar to the if-block, but moving backward in the text. + while (this.position > target) { + if (this.currentChunkPosition <= target) { + // The target is within the current chunk. + const newOffset = roundUp ? 0 : target - this.currentChunkPosition; + if (read) + result = + this.currentChunk.data.substring(newOffset, this.offsetInChunk) + + result; + this.offsetInChunk = newOffset; + break; + } else { + const [data, previousChunk] = this._readToPreviousChunk(); + if (read) result = data + result; + if (previousChunk === null) { + if (lessIsFine) break; + else throw new RangeError(E_END); + } + } + } + } + + if (read) return result; + } + + // Read to the start of the next chunk, if any; otherwise to the end of the current chunk. + _readToNextChunk(): [string, TChunk | null] { + const data = this.currentChunk.data.substring(this.offsetInChunk); + const chunkLength = this.currentChunk.data.length; + const nextChunk = this.chunker.nextChunk(); + if (nextChunk !== null) { + this.currentChunkPosition += chunkLength; + this.offsetInChunk = 0; + } else { + this.offsetInChunk = chunkLength; + } + return [data, nextChunk]; + } + + // Read backwards to the end of the previous chunk, if any; otherwise to the start of the current chunk. + _readToPreviousChunk(): [string, TChunk | null] { + const data = this.currentChunk.data.substring(0, this.offsetInChunk); + const previousChunk = this.chunker.previousChunk(); + if (previousChunk !== null) { + this.currentChunkPosition -= this.currentChunk.data.length; + this.offsetInChunk = this.currentChunk.data.length; + } else { + this.offsetInChunk = 0; + } + return [data, previousChunk]; + } +} diff --git a/src/third_party/apache-annotator/selector/types.ts b/src/third_party/apache-annotator/selector/types.ts new file mode 100644 index 000000000..74148260f --- /dev/null +++ b/src/third_party/apache-annotator/selector/types.ts @@ -0,0 +1,108 @@ +/** + * @license + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * SPDX-FileCopyrightText: The Apache Software Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A {@link https://www.w3.org/TR/2017/REC-annotation-model-20170223/#selectors + * | Selector} object of the Web Annotation Data Model. + * + * Corresponds to RDF class {@link http://www.w3.org/ns/oa#Selector} + * + * @public + */ +export interface Selector { + /** + * A Selector can be refined by another Selector. + * + * See {@link https://www.w3.org/TR/2017/REC-annotation-model-20170223/#refinement-of-selection + * | §4.2.9 Refinement of Selection} in the Web Annotation Data Model. + * + * Corresponds to RDF property {@link http://www.w3.org/ns/oa#refinedBy} + */ + refinedBy?: Selector; +} + +/** + * The {@link https://www.w3.org/TR/2017/REC-annotation-model-20170223/#css-selector + * | CssSelector} of the Web Annotation Data Model. + * + * Corresponds to RDF class {@link http://www.w3.org/ns/oa#CssSelector} + * + * @public + */ +export interface CssSelector extends Selector { + type: "CssSelector"; + value: string; +} + +/** + * The {@link https://www.w3.org/TR/2017/REC-annotation-model-20170223/#text-quote-selector + * | TextQuoteSelector} of the Web Annotation Data Model. + * + * Corresponds to RDF class {@link http://www.w3.org/ns/oa#TextQuoteSelector} + * + * @public + */ +export interface TextQuoteSelector extends Selector { + type: "TextQuoteSelector"; + exact: string; + prefix?: string; + suffix?: string; +} + +/** + * The {@link https://www.w3.org/TR/2017/REC-annotation-model-20170223/#text-position-selector + * | TextPositionSelector} of the Web Annotation Data Model. + * + * Corresponds to RDF class {@link http://www.w3.org/ns/oa#TextPositionSelector} + * + * @public + */ +export interface TextPositionSelector extends Selector { + type: "TextPositionSelector"; + start: number; // more precisely: non-negative integer + end: number; // more precisely: non-negative integer +} + +/** + * The {@link https://www.w3.org/TR/2017/REC-annotation-model-20170223/#range-selector + * | RangeSelector} of the Web Annotation Data Model. + * + * Corresponds to RDF class {@link http://www.w3.org/ns/oa#RangeSelector} + * + * @public + */ +export interface RangeSelector extends Selector { + type: "RangeSelector"; + startSelector: T; + endSelector: T; +} + +/** + * A function that finds the match(es) in the given (sub)document (the ‘scope’) + * corresponding to some (prespecified) selector(s). + * + * @public + */ +export interface Matcher { + (scope: TScope): AsyncGenerator; +} diff --git a/src/typings/en.translation-keys.d.ts b/src/typings/en.translation-keys.d.ts index e117bb2ed..6cc31a612 100644 --- a/src/typings/en.translation-keys.d.ts +++ b/src/typings/en.translation-keys.d.ts @@ -1,4 +1,4 @@ declare namespace typed_i18n_keys { - type TTranslatorKeyParameter = "accessibility" | "accessibility.bookMenu" | "accessibility.closeDialog" | "accessibility.importFile" | "accessibility.leftSlideButton" | "accessibility.mainContent" | "accessibility.rightSlideButton" | "accessibility.skipLink" | "accessibility.toolbar" | "apiapp" | "apiapp.documentation" | "apiapp.howItWorks" | "apiapp.informations" | "apiapp.noLibraryFound" | "app" | "app.edit" | "app.edit.copy" | "app.edit.cut" | "app.edit.paste" | "app.edit.redo" | "app.edit.selectAll" | "app.edit.title" | "app.edit.undo" | "app.hide" | "app.quit" | "app.session" | "app.session.exit" | "app.session.exit.askBox" | "app.session.exit.askBox.button" | "app.session.exit.askBox.button.no" | "app.session.exit.askBox.button.yes" | "app.session.exit.askBox.message" | "app.session.exit.askBox.title" | "app.update" | "app.update.message" | "app.update.title" | "app.window" | "app.window.showLibrary" | "catalog" | "catalog.about" | "catalog.about.title" | "catalog.addBookToLib" | "catalog.addTags" | "catalog.addTagsButton" | "catalog.allBooks" | "catalog.bookInfo" | "catalog.column" | "catalog.column.ascending" | "catalog.column.descending" | "catalog.column.unsorted" | "catalog.delete" | "catalog.deleteBook" | "catalog.deleteTag" | "catalog.description" | "catalog.emptyTagList" | "catalog.entry" | "catalog.entry.continueReading" | "catalog.entry.lastAdditions" | "catalog.export" | "catalog.exportAnnotation" | "catalog.format" | "catalog.importAnnotation" | "catalog.lang" | "catalog.lastRead" | "catalog.moreInfo" | "catalog.myBooks" | "catalog.noPublicationHelpL1" | "catalog.noPublicationHelpL2" | "catalog.noPublicationHelpL3" | "catalog.noPublicationHelpL4" | "catalog.numberOfPages" | "catalog.opds" | "catalog.opds.auth" | "catalog.opds.auth.cancel" | "catalog.opds.auth.login" | "catalog.opds.auth.password" | "catalog.opds.auth.register" | "catalog.opds.auth.username" | "catalog.opds.info" | "catalog.opds.info.availableSince" | "catalog.opds.info.availableState" | "catalog.opds.info.availableState.available" | "catalog.opds.info.availableState.ready" | "catalog.opds.info.availableState.reserved" | "catalog.opds.info.availableState.unavailable" | "catalog.opds.info.availableState.unknown" | "catalog.opds.info.availableUntil" | "catalog.opds.info.copyAvalaible" | "catalog.opds.info.copyTotal" | "catalog.opds.info.holdPosition" | "catalog.opds.info.holdTotal" | "catalog.opds.info.numberOfItems" | "catalog.opds.info.priveValue" | "catalog.opds.info.state" | "catalog.publisher" | "catalog.readBook" | "catalog.released" | "catalog.sort" | "catalog.tag" | "catalog.tags" | "catalog.update" | "dialog" | "dialog.annotations" | "dialog.annotations.descAuthor" | "dialog.annotations.descList" | "dialog.annotations.descNewer" | "dialog.annotations.descOlder" | "dialog.annotations.descTitle" | "dialog.annotations.importAll" | "dialog.annotations.importWithoutConflict" | "dialog.annotations.title" | "dialog.cancel" | "dialog.deleteAnnotations" | "dialog.deleteAnnotationsText" | "dialog.deleteFeed" | "dialog.deletePublication" | "dialog.import" | "dialog.importError" | "dialog.renew" | "dialog.return" | "dialog.yes" | "error" | "error.errorBox" | "error.errorBox.error" | "error.errorBox.message" | "error.errorBox.title" | "header" | "header.allBooks" | "header.catalogs" | "header.downloads" | "header.fitlerTagTitle" | "header.gridTitle" | "header.home" | "header.homeTitle" | "header.importTitle" | "header.listTitle" | "header.myCatalogs" | "header.refreshTitle" | "header.searchPlaceholder" | "header.searchTitle" | "header.settings" | "header.viewMode" | "library" | "library.lcp" | "library.lcp.hint" | "library.lcp.open" | "library.lcp.password" | "library.lcp.sentence" | "library.lcp.urlHint" | "library.lcp.whatIsLcp?" | "library.lcp.whatIsLcpInfoDetails" | "library.lcp.whatIsLcpInfoDetailsLink" | "message" | "message.annotations" | "message.annotations.alreadyImported" | "message.annotations.emptyFile" | "message.annotations.errorParsing" | "message.annotations.noBelongTo" | "message.annotations.nothing" | "message.annotations.success" | "message.download" | "message.download.error" | "message.import" | "message.import.alreadyImport" | "message.import.fail" | "message.import.success" | "message.open" | "message.open.error" | "opds" | "opds.addForm" | "opds.addForm.addButton" | "opds.addForm.name" | "opds.addForm.namePlaceholder" | "opds.addForm.url" | "opds.addForm.urlPlaceholder" | "opds.addFormApiapp" | "opds.addFormApiapp.title" | "opds.addMenu" | "opds.breadcrumbRoot" | "opds.documentation" | "opds.empty" | "opds.firstPage" | "opds.informations" | "opds.lastPage" | "opds.menu" | "opds.menu.aboutBook" | "opds.menu.addExtract" | "opds.menu.goBuyBook" | "opds.menu.goLoanBook" | "opds.menu.goRevokeLoanBook" | "opds.menu.goSubBook" | "opds.network" | "opds.network.error" | "opds.network.noInternet" | "opds.network.noInternetMessage" | "opds.network.reject" | "opds.network.timeout" | "opds.next" | "opds.previous" | "opds.shelf" | "opds.updateForm" | "opds.updateForm.name" | "opds.updateForm.title" | "opds.updateForm.updateButton" | "opds.updateForm.url" | "opds.whatIsOpds" | "publication" | "publication.accessibility" | "publication.accessibility.accessModeSufficient" | "publication.accessibility.accessModeSufficient.textual" | "publication.accessibility.accessibilityFeature" | "publication.accessibility.accessibilityFeature.alternativeText" | "publication.accessibility.accessibilityFeature.displayTransformability" | "publication.accessibility.accessibilityFeature.longDescription" | "publication.accessibility.accessibilityFeature.printPageNumbers" | "publication.accessibility.accessibilityFeature.readingOrder" | "publication.accessibility.accessibilityFeature.synchronizedAudioText" | "publication.accessibility.accessibilityFeature.tableOfContents" | "publication.accessibility.accessibilityHazard" | "publication.accessibility.accessibilityHazard.flashing" | "publication.accessibility.accessibilityHazard.motionSimulation" | "publication.accessibility.accessibilityHazard.name" | "publication.accessibility.accessibilityHazard.noFlashing" | "publication.accessibility.accessibilityHazard.noMotionSimulation" | "publication.accessibility.accessibilityHazard.noSound" | "publication.accessibility.accessibilityHazard.none" | "publication.accessibility.accessibilityHazard.sound" | "publication.accessibility.accessibilityHazard.unknown" | "publication.accessibility.certifierReport" | "publication.accessibility.conformsTo" | "publication.accessibility.moreInformation" | "publication.accessibility.name" | "publication.accessibility.noA11y" | "publication.actions" | "publication.audio" | "publication.audio.tracks" | "publication.author" | "publication.cancelledLcp" | "publication.certificateRevoked" | "publication.certificateSignatureInvalid" | "publication.cover" | "publication.cover.img" | "publication.day" | "publication.days" | "publication.duration" | "publication.duration.title" | "publication.encryptedNoLicense" | "publication.expired" | "publication.expiredLcp" | "publication.incorrectPassphrase" | "publication.lcpEnd" | "publication.lcpRightsCopy" | "publication.lcpRightsPrint" | "publication.lcpStart" | "publication.licenceLCP" | "publication.licenseOutOfDate" | "publication.licenseCertificateDateInvalid" | "publication.licenseSignatureInvalid" | "publication.licensed" | "publication.markAsRead" | "publication.notStarted" | "publication.onGoing" | "publication.progression" | "publication.progression.title" | "publication.read" | "publication.remainingTime" | "publication.renewButton" | "publication.returnButton" | "publication.returnedLcp" | "publication.revokedLcp" | "publication.seeLess" | "publication.seeMore" | "publication.timeLeft" | "publication.title" | "publication.userKeyCheckInvalid" | "reader" | "reader.annotations" | "reader.annotations.Color" | "reader.annotations.addNote" | "reader.annotations.advancedMode" | "reader.annotations.annotationsExport" | "reader.annotations.annotationsExport.description" | "reader.annotations.annotationsExport.title" | "reader.annotations.annotationsOptions" | "reader.annotations.colors" | "reader.annotations.colors.bluegreen" | "reader.annotations.colors.cyan" | "reader.annotations.colors.green" | "reader.annotations.colors.lightblue" | "reader.annotations.colors.orange" | "reader.annotations.colors.purple" | "reader.annotations.colors.red" | "reader.annotations.colors.yellow" | "reader.annotations.export" | "reader.annotations.filter" | "reader.annotations.filter.all" | "reader.annotations.filter.filterByColor" | "reader.annotations.filter.filterByCreator" | "reader.annotations.filter.filterByDrawtype" | "reader.annotations.filter.filterByTag" | "reader.annotations.filter.filterOptions" | "reader.annotations.filter.none" | "reader.annotations.hide" | "reader.annotations.highlight" | "reader.annotations.noSelectionToast" | "reader.annotations.quickAnnotations" | "reader.annotations.saveNote" | "reader.annotations.sorting" | "reader.annotations.sorting.lastcreated" | "reader.annotations.sorting.lastmodified" | "reader.annotations.sorting.progression" | "reader.annotations.sorting.sortingOptions" | "reader.annotations.toggleMarginMarks" | "reader.annotations.type" | "reader.annotations.type.outline" | "reader.annotations.type.solid" | "reader.annotations.type.strikethrough" | "reader.annotations.type.underline" | "reader.divina" | "reader.divina.mute" | "reader.divina.unmute" | "reader.fxl" | "reader.fxl.fit" | "reader.marks" | "reader.marks.annotations" | "reader.marks.bookmarks" | "reader.marks.delete" | "reader.marks.edit" | "reader.marks.goTo" | "reader.marks.landmarks" | "reader.marks.saveMark" | "reader.marks.search" | "reader.marks.searchResult" | "reader.marks.toc" | "reader.media-overlays" | "reader.media-overlays.activate" | "reader.media-overlays.captions" | "reader.media-overlays.captionsDescription" | "reader.media-overlays.next" | "reader.media-overlays.pause" | "reader.media-overlays.play" | "reader.media-overlays.previous" | "reader.media-overlays.skip" | "reader.media-overlays.skipDescription" | "reader.media-overlays.speed" | "reader.media-overlays.stop" | "reader.media-overlays.title" | "reader.navigation" | "reader.navigation.annotationTitle" | "reader.navigation.backHomeTitle" | "reader.navigation.bookmarkTitle" | "reader.navigation.currentPage" | "reader.navigation.currentPageTotal" | "reader.navigation.detachWindowTitle" | "reader.navigation.fullscreenTitle" | "reader.navigation.goTo" | "reader.navigation.goToError" | "reader.navigation.goToPlaceHolder" | "reader.navigation.goToTitle" | "reader.navigation.historyNext" | "reader.navigation.historyPrevious" | "reader.navigation.infoTitle" | "reader.navigation.magnifyingGlassButton" | "reader.navigation.openTableOfContentsTitle" | "reader.navigation.page" | "reader.navigation.pdfscalemode" | "reader.navigation.settingsTitle" | "reader.picker" | "reader.picker.search" | "reader.picker.search.founds" | "reader.picker.search.input" | "reader.picker.search.next" | "reader.picker.search.notFound" | "reader.picker.search.previous" | "reader.picker.search.results" | "reader.picker.search.submit" | "reader.picker.searchTitle" | "reader.settings" | "reader.settings.column" | "reader.settings.column.auto" | "reader.settings.column.one" | "reader.settings.column.title" | "reader.settings.column.two" | "reader.settings.customFontSelected" | "reader.settings.customizeReader" | "reader.settings.disabled" | "reader.settings.display" | "reader.settings.disposition" | "reader.settings.disposition.title" | "reader.settings.font" | "reader.settings.fontSize" | "reader.settings.infoCustomFont" | "reader.settings.justification" | "reader.settings.justify" | "reader.settings.letterSpacing" | "reader.settings.lineSpacing" | "reader.settings.margin" | "reader.settings.noFootnotes" | "reader.settings.noRTLFlip" | "reader.settings.noRuby" | "reader.settings.paginated" | "reader.settings.paraSpacing" | "reader.settings.pdfZoom" | "reader.settings.pdfZoom.name" | "reader.settings.pdfZoom.name.100pct" | "reader.settings.pdfZoom.name.150pct" | "reader.settings.pdfZoom.name.200pct" | "reader.settings.pdfZoom.name.300pct" | "reader.settings.pdfZoom.name.500pct" | "reader.settings.pdfZoom.name.50pct" | "reader.settings.pdfZoom.name.fit" | "reader.settings.pdfZoom.name.width" | "reader.settings.pdfZoom.title" | "reader.settings.preset" | "reader.settings.preset.apply" | "reader.settings.preset.applyDetails" | "reader.settings.preset.detail" | "reader.settings.preset.reset" | "reader.settings.preset.resetDetails" | "reader.settings.preset.save" | "reader.settings.preset.saveDetails" | "reader.settings.preset.title" | "reader.settings.preview" | "reader.settings.reduceMotion" | "reader.settings.scrolled" | "reader.settings.spacing" | "reader.settings.text" | "reader.settings.theme" | "reader.settings.theme.name" | "reader.settings.theme.name.Contrast1" | "reader.settings.theme.name.Contrast2" | "reader.settings.theme.name.Contrast3" | "reader.settings.theme.name.Contrast4" | "reader.settings.theme.name.Neutral" | "reader.settings.theme.name.Night" | "reader.settings.theme.name.Paper" | "reader.settings.theme.name.Sepia" | "reader.settings.theme.title" | "reader.settings.wordSpacing" | "reader.svg" | "reader.svg.left" | "reader.svg.right" | "reader.toc" | "reader.toc.publicationNoToc" | "reader.tts" | "reader.tts.activate" | "reader.tts.default" | "reader.tts.language" | "reader.tts.next" | "reader.tts.pause" | "reader.tts.play" | "reader.tts.previous" | "reader.tts.sentenceDetect" | "reader.tts.sentenceDetectDescription" | "reader.tts.speed" | "reader.tts.stop" | "reader.tts.voice" | "settings" | "settings.annotationCreator" | "settings.annotationCreator.creator" | "settings.annotationCreator.name" | "settings.annotationCreator.organization" | "settings.annotationCreator.person" | "settings.annotationCreator.type" | "settings.auth" | "settings.auth.title" | "settings.auth.wipeData" | "settings.keyboard" | "settings.keyboard.advancedMenu" | "settings.keyboard.cancel" | "settings.keyboard.disclaimer" | "settings.keyboard.editUserJson" | "settings.keyboard.keyboardShortcuts" | "settings.keyboard.loadUserJson" | "settings.keyboard.resetDefaults" | "settings.keyboard.save" | "settings.language" | "settings.language.languageChoice" | "settings.library" | "settings.library.enableAPIAPP" | "settings.library.title" | "settings.session" | "settings.session.title" | "settings.tabs" | "settings.tabs.appearance" | "settings.tabs.general" | "settings.tabs.keyboardShortcuts" | "settings.theme" | "settings.theme.auto" | "settings.theme.dark" | "settings.theme.description" | "settings.theme.light" | "settings.theme.title" | "wizard" | "wizard.buttons" | "wizard.buttons.discover" | "wizard.buttons.goToBooks" | "wizard.buttons.next" | "wizard.description" | "wizard.description.annotations" | "wizard.description.catalogs" | "wizard.description.home" | "wizard.description.readingView1" | "wizard.description.readingView2" | "wizard.description.yourBooks" | "wizard.dontShow" | "wizard.tab" | "wizard.tab.annotations" | "wizard.tab.catalogs" | "wizard.tab.home" | "wizard.tab.readingView" | "wizard.tab.yourBooks" | "wizard.title" | "wizard.title.allBooks" | "wizard.title.newFeature" | "wizard.title.welcome"; + type TTranslatorKeyParameter = "accessibility" | "accessibility.bookMenu" | "accessibility.closeDialog" | "accessibility.importFile" | "accessibility.leftSlideButton" | "accessibility.mainContent" | "accessibility.rightSlideButton" | "accessibility.skipLink" | "accessibility.toolbar" | "apiapp" | "apiapp.documentation" | "apiapp.howItWorks" | "apiapp.informations" | "apiapp.noLibraryFound" | "app" | "app.edit" | "app.edit.copy" | "app.edit.cut" | "app.edit.paste" | "app.edit.redo" | "app.edit.selectAll" | "app.edit.title" | "app.edit.undo" | "app.hide" | "app.quit" | "app.session" | "app.session.exit" | "app.session.exit.askBox" | "app.session.exit.askBox.button" | "app.session.exit.askBox.button.no" | "app.session.exit.askBox.button.yes" | "app.session.exit.askBox.message" | "app.session.exit.askBox.title" | "app.update" | "app.update.message" | "app.update.title" | "app.window" | "app.window.showLibrary" | "catalog" | "catalog.about" | "catalog.about.title" | "catalog.addBookToLib" | "catalog.addTags" | "catalog.addTagsButton" | "catalog.allBooks" | "catalog.bookInfo" | "catalog.column" | "catalog.column.ascending" | "catalog.column.descending" | "catalog.column.unsorted" | "catalog.delete" | "catalog.deleteBook" | "catalog.deleteTag" | "catalog.description" | "catalog.emptyTagList" | "catalog.entry" | "catalog.entry.continueReading" | "catalog.entry.lastAdditions" | "catalog.export" | "catalog.exportAnnotation" | "catalog.format" | "catalog.importAnnotation" | "catalog.lang" | "catalog.lastRead" | "catalog.moreInfo" | "catalog.myBooks" | "catalog.noPublicationHelpL1" | "catalog.noPublicationHelpL2" | "catalog.noPublicationHelpL3" | "catalog.noPublicationHelpL4" | "catalog.numberOfPages" | "catalog.opds" | "catalog.opds.auth" | "catalog.opds.auth.cancel" | "catalog.opds.auth.login" | "catalog.opds.auth.password" | "catalog.opds.auth.register" | "catalog.opds.auth.username" | "catalog.opds.info" | "catalog.opds.info.availableSince" | "catalog.opds.info.availableState" | "catalog.opds.info.availableState.available" | "catalog.opds.info.availableState.ready" | "catalog.opds.info.availableState.reserved" | "catalog.opds.info.availableState.unavailable" | "catalog.opds.info.availableState.unknown" | "catalog.opds.info.availableUntil" | "catalog.opds.info.copyAvalaible" | "catalog.opds.info.copyTotal" | "catalog.opds.info.holdPosition" | "catalog.opds.info.holdTotal" | "catalog.opds.info.numberOfItems" | "catalog.opds.info.priveValue" | "catalog.opds.info.state" | "catalog.publisher" | "catalog.readBook" | "catalog.released" | "catalog.sort" | "catalog.tag" | "catalog.tags" | "catalog.update" | "dialog" | "dialog.annotations" | "dialog.annotations.descAuthor" | "dialog.annotations.descCreator" | "dialog.annotations.descList" | "dialog.annotations.descNewer" | "dialog.annotations.descOlder" | "dialog.annotations.descTitle" | "dialog.annotations.importAll" | "dialog.annotations.importWithoutConflict" | "dialog.annotations.origin" | "dialog.annotations.title" | "dialog.cancel" | "dialog.deleteAnnotations" | "dialog.deleteAnnotationsText" | "dialog.deleteFeed" | "dialog.deletePublication" | "dialog.import" | "dialog.importError" | "dialog.renew" | "dialog.return" | "dialog.yes" | "error" | "error.errorBox" | "error.errorBox.error" | "error.errorBox.message" | "error.errorBox.title" | "header" | "header.allBooks" | "header.catalogs" | "header.downloads" | "header.fitlerTagTitle" | "header.gridTitle" | "header.home" | "header.homeTitle" | "header.importTitle" | "header.listTitle" | "header.myCatalogs" | "header.refreshTitle" | "header.searchPlaceholder" | "header.searchTitle" | "header.settings" | "header.viewMode" | "library" | "library.lcp" | "library.lcp.hint" | "library.lcp.open" | "library.lcp.password" | "library.lcp.sentence" | "library.lcp.urlHint" | "library.lcp.whatIsLcp?" | "library.lcp.whatIsLcpInfoDetails" | "library.lcp.whatIsLcpInfoDetailsLink" | "message" | "message.annotations" | "message.annotations.alreadyImported" | "message.annotations.emptyFile" | "message.annotations.errorParsing" | "message.annotations.noBelongTo" | "message.annotations.nothing" | "message.annotations.success" | "message.download" | "message.download.error" | "message.import" | "message.import.alreadyImport" | "message.import.fail" | "message.import.success" | "message.open" | "message.open.error" | "opds" | "opds.addForm" | "opds.addForm.addButton" | "opds.addForm.name" | "opds.addForm.namePlaceholder" | "opds.addForm.url" | "opds.addForm.urlPlaceholder" | "opds.addFormApiapp" | "opds.addFormApiapp.title" | "opds.addMenu" | "opds.breadcrumbRoot" | "opds.documentation" | "opds.empty" | "opds.firstPage" | "opds.informations" | "opds.lastPage" | "opds.menu" | "opds.menu.aboutBook" | "opds.menu.addExtract" | "opds.menu.goBuyBook" | "opds.menu.goLoanBook" | "opds.menu.goRevokeLoanBook" | "opds.menu.goSubBook" | "opds.network" | "opds.network.error" | "opds.network.noInternet" | "opds.network.noInternetMessage" | "opds.network.reject" | "opds.network.timeout" | "opds.next" | "opds.previous" | "opds.shelf" | "opds.updateForm" | "opds.updateForm.name" | "opds.updateForm.title" | "opds.updateForm.updateButton" | "opds.updateForm.url" | "opds.whatIsOpds" | "publication" | "publication.accessibility" | "publication.accessibility.accessModeSufficient" | "publication.accessibility.accessModeSufficient.textual" | "publication.accessibility.accessibilityFeature" | "publication.accessibility.accessibilityFeature.alternativeText" | "publication.accessibility.accessibilityFeature.displayTransformability" | "publication.accessibility.accessibilityFeature.longDescription" | "publication.accessibility.accessibilityFeature.printPageNumbers" | "publication.accessibility.accessibilityFeature.readingOrder" | "publication.accessibility.accessibilityFeature.synchronizedAudioText" | "publication.accessibility.accessibilityFeature.tableOfContents" | "publication.accessibility.accessibilityHazard" | "publication.accessibility.accessibilityHazard.flashing" | "publication.accessibility.accessibilityHazard.motionSimulation" | "publication.accessibility.accessibilityHazard.name" | "publication.accessibility.accessibilityHazard.noFlashing" | "publication.accessibility.accessibilityHazard.noMotionSimulation" | "publication.accessibility.accessibilityHazard.noSound" | "publication.accessibility.accessibilityHazard.none" | "publication.accessibility.accessibilityHazard.sound" | "publication.accessibility.accessibilityHazard.unknown" | "publication.accessibility.certifierReport" | "publication.accessibility.conformsTo" | "publication.accessibility.moreInformation" | "publication.accessibility.name" | "publication.accessibility.noA11y" | "publication.actions" | "publication.audio" | "publication.audio.tracks" | "publication.author" | "publication.cancelledLcp" | "publication.certificateRevoked" | "publication.certificateSignatureInvalid" | "publication.cover" | "publication.cover.img" | "publication.day" | "publication.days" | "publication.duration" | "publication.duration.title" | "publication.encryptedNoLicense" | "publication.expired" | "publication.expiredLcp" | "publication.incorrectPassphrase" | "publication.lcpEnd" | "publication.lcpRightsCopy" | "publication.lcpRightsPrint" | "publication.lcpStart" | "publication.licenceLCP" | "publication.licenseOutOfDate" | "publication.licenseCertificateDateInvalid" | "publication.licenseSignatureInvalid" | "publication.licensed" | "publication.markAsRead" | "publication.notStarted" | "publication.onGoing" | "publication.progression" | "publication.progression.title" | "publication.read" | "publication.remainingTime" | "publication.renewButton" | "publication.returnButton" | "publication.returnedLcp" | "publication.revokedLcp" | "publication.seeLess" | "publication.seeMore" | "publication.timeLeft" | "publication.title" | "publication.userKeyCheckInvalid" | "reader" | "reader.annotations" | "reader.annotations.Color" | "reader.annotations.addNote" | "reader.annotations.advancedMode" | "reader.annotations.annotationsExport" | "reader.annotations.annotationsExport.description" | "reader.annotations.annotationsExport.title" | "reader.annotations.annotationsOptions" | "reader.annotations.colors" | "reader.annotations.colors.bluegreen" | "reader.annotations.colors.cyan" | "reader.annotations.colors.green" | "reader.annotations.colors.lightblue" | "reader.annotations.colors.orange" | "reader.annotations.colors.purple" | "reader.annotations.colors.red" | "reader.annotations.colors.yellow" | "reader.annotations.export" | "reader.annotations.filter" | "reader.annotations.filter.all" | "reader.annotations.filter.filterByColor" | "reader.annotations.filter.filterByCreator" | "reader.annotations.filter.filterByDrawtype" | "reader.annotations.filter.filterByTag" | "reader.annotations.filter.filterOptions" | "reader.annotations.filter.none" | "reader.annotations.hide" | "reader.annotations.highlight" | "reader.annotations.noSelectionToast" | "reader.annotations.quickAnnotations" | "reader.annotations.saveNote" | "reader.annotations.sorting" | "reader.annotations.sorting.lastcreated" | "reader.annotations.sorting.lastmodified" | "reader.annotations.sorting.progression" | "reader.annotations.sorting.sortingOptions" | "reader.annotations.toggleMarginMarks" | "reader.annotations.type" | "reader.annotations.type.outline" | "reader.annotations.type.solid" | "reader.annotations.type.strikethrough" | "reader.annotations.type.underline" | "reader.divina" | "reader.divina.mute" | "reader.divina.unmute" | "reader.fxl" | "reader.fxl.fit" | "reader.marks" | "reader.marks.annotations" | "reader.marks.bookmarks" | "reader.marks.delete" | "reader.marks.edit" | "reader.marks.goTo" | "reader.marks.landmarks" | "reader.marks.saveMark" | "reader.marks.search" | "reader.marks.searchResult" | "reader.marks.toc" | "reader.media-overlays" | "reader.media-overlays.activate" | "reader.media-overlays.captions" | "reader.media-overlays.captionsDescription" | "reader.media-overlays.next" | "reader.media-overlays.pause" | "reader.media-overlays.play" | "reader.media-overlays.previous" | "reader.media-overlays.skip" | "reader.media-overlays.skipDescription" | "reader.media-overlays.speed" | "reader.media-overlays.stop" | "reader.media-overlays.title" | "reader.navigation" | "reader.navigation.annotationTitle" | "reader.navigation.backHomeTitle" | "reader.navigation.bookmarkTitle" | "reader.navigation.currentPage" | "reader.navigation.currentPageTotal" | "reader.navigation.detachWindowTitle" | "reader.navigation.fullscreenTitle" | "reader.navigation.goTo" | "reader.navigation.goToError" | "reader.navigation.goToPlaceHolder" | "reader.navigation.goToTitle" | "reader.navigation.historyNext" | "reader.navigation.historyPrevious" | "reader.navigation.infoTitle" | "reader.navigation.magnifyingGlassButton" | "reader.navigation.openTableOfContentsTitle" | "reader.navigation.page" | "reader.navigation.pdfscalemode" | "reader.navigation.settingsTitle" | "reader.picker" | "reader.picker.search" | "reader.picker.search.founds" | "reader.picker.search.input" | "reader.picker.search.next" | "reader.picker.search.notFound" | "reader.picker.search.previous" | "reader.picker.search.results" | "reader.picker.search.submit" | "reader.picker.searchTitle" | "reader.settings" | "reader.settings.column" | "reader.settings.column.auto" | "reader.settings.column.one" | "reader.settings.column.title" | "reader.settings.column.two" | "reader.settings.customFontSelected" | "reader.settings.customizeReader" | "reader.settings.disabled" | "reader.settings.display" | "reader.settings.disposition" | "reader.settings.disposition.title" | "reader.settings.font" | "reader.settings.fontSize" | "reader.settings.infoCustomFont" | "reader.settings.justification" | "reader.settings.justify" | "reader.settings.letterSpacing" | "reader.settings.lineSpacing" | "reader.settings.margin" | "reader.settings.noFootnotes" | "reader.settings.noRTLFlip" | "reader.settings.noRuby" | "reader.settings.paginated" | "reader.settings.paraSpacing" | "reader.settings.pdfZoom" | "reader.settings.pdfZoom.name" | "reader.settings.pdfZoom.name.100pct" | "reader.settings.pdfZoom.name.150pct" | "reader.settings.pdfZoom.name.200pct" | "reader.settings.pdfZoom.name.300pct" | "reader.settings.pdfZoom.name.500pct" | "reader.settings.pdfZoom.name.50pct" | "reader.settings.pdfZoom.name.fit" | "reader.settings.pdfZoom.name.width" | "reader.settings.pdfZoom.title" | "reader.settings.preset" | "reader.settings.preset.apply" | "reader.settings.preset.applyDetails" | "reader.settings.preset.detail" | "reader.settings.preset.reset" | "reader.settings.preset.resetDetails" | "reader.settings.preset.save" | "reader.settings.preset.saveDetails" | "reader.settings.preset.title" | "reader.settings.preview" | "reader.settings.reduceMotion" | "reader.settings.scrolled" | "reader.settings.spacing" | "reader.settings.text" | "reader.settings.theme" | "reader.settings.theme.name" | "reader.settings.theme.name.Contrast1" | "reader.settings.theme.name.Contrast2" | "reader.settings.theme.name.Contrast3" | "reader.settings.theme.name.Contrast4" | "reader.settings.theme.name.Neutral" | "reader.settings.theme.name.Night" | "reader.settings.theme.name.Paper" | "reader.settings.theme.name.Sepia" | "reader.settings.theme.title" | "reader.settings.wordSpacing" | "reader.svg" | "reader.svg.left" | "reader.svg.right" | "reader.toc" | "reader.toc.publicationNoToc" | "reader.tts" | "reader.tts.activate" | "reader.tts.default" | "reader.tts.language" | "reader.tts.next" | "reader.tts.pause" | "reader.tts.play" | "reader.tts.previous" | "reader.tts.sentenceDetect" | "reader.tts.sentenceDetectDescription" | "reader.tts.speed" | "reader.tts.stop" | "reader.tts.voice" | "settings" | "settings.annotationCreator" | "settings.annotationCreator.creator" | "settings.annotationCreator.name" | "settings.annotationCreator.organization" | "settings.annotationCreator.person" | "settings.annotationCreator.type" | "settings.auth" | "settings.auth.title" | "settings.auth.wipeData" | "settings.keyboard" | "settings.keyboard.advancedMenu" | "settings.keyboard.cancel" | "settings.keyboard.disclaimer" | "settings.keyboard.editUserJson" | "settings.keyboard.keyboardShortcuts" | "settings.keyboard.loadUserJson" | "settings.keyboard.resetDefaults" | "settings.keyboard.save" | "settings.language" | "settings.language.languageChoice" | "settings.library" | "settings.library.enableAPIAPP" | "settings.library.title" | "settings.session" | "settings.session.title" | "settings.tabs" | "settings.tabs.appearance" | "settings.tabs.general" | "settings.tabs.keyboardShortcuts" | "settings.theme" | "settings.theme.auto" | "settings.theme.dark" | "settings.theme.description" | "settings.theme.light" | "settings.theme.title" | "wizard" | "wizard.buttons" | "wizard.buttons.discover" | "wizard.buttons.goToBooks" | "wizard.buttons.next" | "wizard.description" | "wizard.description.annotations" | "wizard.description.catalogs" | "wizard.description.home" | "wizard.description.readingView1" | "wizard.description.readingView2" | "wizard.description.yourBooks" | "wizard.dontShow" | "wizard.tab" | "wizard.tab.annotations" | "wizard.tab.catalogs" | "wizard.tab.home" | "wizard.tab.readingView" | "wizard.tab.yourBooks" | "wizard.title" | "wizard.title.allBooks" | "wizard.title.newFeature" | "wizard.title.welcome"; } -export = typed_i18n_keys; +export = typed_i18n_keys; \ No newline at end of file diff --git a/src/typings/en.translation.d.ts b/src/typings/en.translation.d.ts index 8faaa7e8c..09cae97f9 100644 --- a/src/typings/en.translation.d.ts +++ b/src/typings/en.translation.d.ts @@ -292,12 +292,14 @@ declare namespace typed_i18n { (_: "dialog", __?: {}): { readonly "annotations": { readonly "descAuthor": string, + readonly "descCreator": string, readonly "descList": string, readonly "descNewer": string, readonly "descOlder": string, readonly "descTitle": string, readonly "importAll": string, readonly "importWithoutConflict": string, + readonly "origin": string, readonly "title": string }, readonly "cancel": string, @@ -313,21 +315,25 @@ declare namespace typed_i18n { }; (_: "dialog.annotations", __?: {}): { readonly "descAuthor": string, + readonly "descCreator": string, readonly "descList": string, readonly "descNewer": string, readonly "descOlder": string, readonly "descTitle": string, readonly "importAll": string, readonly "importWithoutConflict": string, + readonly "origin": string, readonly "title": string }; (_: "dialog.annotations.descAuthor", __?: {}): string; + (_: "dialog.annotations.descCreator", __?: {}): string; (_: "dialog.annotations.descList", __?: {}): string; (_: "dialog.annotations.descNewer", __?: {}): string; (_: "dialog.annotations.descOlder", __?: {}): string; (_: "dialog.annotations.descTitle", __?: {}): string; (_: "dialog.annotations.importAll", __?: {}): string; (_: "dialog.annotations.importWithoutConflict", __?: {}): string; + (_: "dialog.annotations.origin", __?: {}): string; (_: "dialog.annotations.title", __?: {}): string; (_: "dialog.cancel", __?: {}): string; (_: "dialog.deleteAnnotations", __?: {}): string; diff --git a/src/utils/redux-reducers/fifo.reducer.ts b/src/utils/redux-reducers/fifo.reducer.ts new file mode 100644 index 000000000..6a90d2daa --- /dev/null +++ b/src/utils/redux-reducers/fifo.reducer.ts @@ -0,0 +1,85 @@ +// ==LICENSE-BEGIN== +// Copyright 2017 European Digital Reading Lab. All rights reserved. +// Licensed to the Readium Foundation under one or more contributor license agreements. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file exposed on Github (readium) in the project repository. +// ==LICENSE-END== + +import { Action, type UnknownAction } from "redux"; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface ActionWithPayload + extends Action { +} + +export interface IFIFOActionPush, Value = string, ActionType extends string = string> { + type: ActionType; + selector: (action: TAction) => TFIFOState; +} + +export interface IFIFOActionPop { + type: ActionType; +} + + +export interface IFIFOData +< + TPushAction extends ActionWithPayload, + Value = string, + ActionType extends string = string, +> { + push: IFIFOActionPush; + shift: IFIFOActionPop; +} + +export type IFIFOState = Value; +export type TFIFOState = Array>; + +export function fifoReducer + < + TPushAction extends ActionWithPayload, + Value = string, + ActionType extends string = string, + >( + data: IFIFOData, +) { + + const reducer = + ( + queue: TFIFOState, + action: UnknownAction, // TPopAction | TPushAction, + ): TFIFOState => { + + if (!queue || !Array.isArray(queue)) { + queue = []; + } + + if (action.type === data.push.type) { + + const selectorItem = data.push.selector(action as unknown as TPushAction); + if (!selectorItem) { + return queue; + } + const newQueue = queue.slice(); + selectorItem.forEach((value) => { + if (value) { + newQueue.push(value); + } + }); + + return newQueue; + + } else if (action.type === data.shift.type) { + + const newQueue = queue.slice(); + newQueue.shift(); + + return newQueue; + } + + return queue; + }; + + return reducer; +} diff --git a/src/utils/search/search.ts b/src/utils/search/search.ts index d70ecb9b7..a66c726b9 100644 --- a/src/utils/search/search.ts +++ b/src/utils/search/search.ts @@ -8,37 +8,29 @@ // import { JSDOM } from "jsdom"; // import * as xmldom from "@xmldom/xmldom"; -import { removeUTF8BOM } from "readium-desktop/common/utils/bom"; - -import { ContentType } from "../contentType"; -import { ISearchDocument, ISearchResult } from "./search.interface"; import { searchDocDomSeek } from "./searchWithDomSeek"; -import { ENABLE_SKIP_LINK } from "@r2-navigator-js/electron/common/styles"; +import { IRangeInfo } from "@r2-navigator-js/electron/common/selection"; +import { ICacheDocument } from "readium-desktop/common/redux/states/renderer/resourceCache"; +import { getDocumentFromICacheDocument } from "../xmlDom"; -export async function search(searchInput: string, data: ISearchDocument): Promise { +export interface ISearchResult { + rangeInfo: IRangeInfo; - if (!data.xml) { - return []; - } - if (!window.DOMParser) { - console.log("NOT RENDERER PROCESS???! (DOMParser for search)"); - return []; - } + cleanBefore: string; + cleanText: string; + cleanAfter: string; - // TODO: this is a hack... - // but rendered reflowable documents have a top-level invisible accessible link injected by the navigator - // so we need it here to compute CSS Selectors - // SKIP_LINK_ID === "r2-skip-link" - let toParse = (!ENABLE_SKIP_LINK || data.isFixedLayout) ? - data.xml : - data.xml.replace( - //gm, - " ", - ); - // console.log(`===data.isFixedLayout ${data.isFixedLayout}`, data.xml); + // rawBefore: string; + // rawText: string; + // rawAfter: string; + + href: string; + uuid: string; +} + +export async function search(searchInput: string, data: ICacheDocument): Promise { - const contentType = data.contentType ? (data.contentType as DOMParserSupportedType) : ContentType.Xhtml; try { // const isRenderer = typeof window !== undefined; // && typeof process === undefined; // const xmlDom = isRenderer ? (new DOMParser()).parseFromString( @@ -49,11 +41,11 @@ export async function search(searchInput: string, data: ISearchDocument): Promis // contentType, // ) : new JSDOM(toParse, { contentType: contentType }).window.document); - toParse = removeUTF8BOM(toParse); - const xmlDom = (new window.DOMParser()).parseFromString( - toParse, - contentType, - ); + const xmlDom = getDocumentFromICacheDocument(data); + if (!xmlDom) { + return []; + // throw new Error("xmlDom not defined !?!"); + } const iter = xmlDom.createNodeIterator( xmlDom.body, diff --git a/src/utils/search/searchWithDomSeek.ts b/src/utils/search/searchWithDomSeek.ts index b991e6d41..57b3fe758 100644 --- a/src/utils/search/searchWithDomSeek.ts +++ b/src/utils/search/searchWithDomSeek.ts @@ -10,8 +10,8 @@ import { convertRange } from "@r2-navigator-js/electron/renderer/webview/selecti import { getCount } from "../counter"; import { getCssSelector_ } from "./cssSelector"; import { escapeRegExp } from "./regexp"; -import { ISearchResult } from "./search.interface"; import { cleanupStr, collapseWhitespaces, equivalents } from "./transliteration"; +import { ISearchResult } from "./search"; export async function searchDocDomSeek(searchInput: string, doc: Document, href: string): Promise { // https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent @@ -243,6 +243,12 @@ export async function searchDocDomSeek(searchInput: string, doc: Document, href: if (!(doc as any).getCssSelector) { (doc as any).getCssSelector = getCssSelector_(doc); } + + if (range.collapsed) { + console.log("SEARCH RANGE COLLAPSED, skipping..."); + continue; + } + // the range start/end is guaranteed in document order due to the search algo above (forward tree walk) ... but DOM Ranges are always ordered anyway (only the user / document selection object can be reversed) const tuple = convertRange( range, (doc as any).getCssSelector, diff --git a/src/utils/xmlDom.ts b/src/utils/xmlDom.ts new file mode 100644 index 000000000..1ca294001 --- /dev/null +++ b/src/utils/xmlDom.ts @@ -0,0 +1,47 @@ +// ==LICENSE-BEGIN== +// Copyright 2017 European Digital Reading Lab. All rights reserved. +// Licensed to the Readium Foundation under one or more contributor license agreements. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file exposed on Github (readium) in the project repository. +// ==LICENSE-END== + +import { ICacheDocument } from "readium-desktop/common/redux/states/renderer/resourceCache"; +import { ContentType } from "./contentType"; +import { removeUTF8BOM } from "readium-desktop/common/utils/bom"; +import { ENABLE_SKIP_LINK } from "@r2-navigator-js/electron/common/styles"; + +export function getDocumentFromICacheDocument(data: ICacheDocument): Document { + + if (!data.xml) { + return undefined; + } + if (!window.DOMParser) { + console.log("NOT RENDERER PROCESS???! (DOMParser for search)"); + return undefined; + } + + // TODO: this is a hack... + // but rendered reflowable documents have a top-level invisible accessible link injected by the navigator + // so we need it here to compute CSS Selectors + let toParse = (!ENABLE_SKIP_LINK || data.isFixedLayout) ? data.xml : data.xml.replace( + //gm, + " ", + ); + // console.log(`===data.isFixedLayout ${data.isFixedLayout}`, data.xml); + + const contentType = data.contentType ? (data.contentType as DOMParserSupportedType) : ContentType.Xhtml; + + try { + toParse = removeUTF8BOM(toParse); + const xmlDom = (new window.DOMParser()).parseFromString( + toParse, + contentType, + ); + + return xmlDom; + } catch { + // nothing + } + + return undefined; +}